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
This commit is contained in:
parent
9dac52da13
commit
b4e479d9d0
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "最新へ移動"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "К последнему сообщению"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "跳转到最新消息"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
24
src/app/ai/chat/history/continuation.ts
Normal file
24
src/app/ai/chat/history/continuation.ts
Normal file
|
|
@ -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<UIMessage>): ChatTransport<UIMessage> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
210
src/app/ai/chat/history/controller.ts
Normal file
210
src/app/ai/chat/history/controller.ts
Normal file
|
|
@ -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<TChat extends HistoryChat>(
|
||||
runtime: HistoryRuntime<TChat>,
|
||||
store: ConversationStore = createConversationStore()
|
||||
) {
|
||||
const current = shallowRef<Conversation | null>(null)
|
||||
const messages = shallowRef<UIMessage[]>([])
|
||||
const conversations = ref<ConversationMeta[]>([])
|
||||
const readOnly = ref(false)
|
||||
const busy = ref(false)
|
||||
let ownerRecoveryId: string | null = null
|
||||
let owner: ChatDocumentEditor | null = null
|
||||
let operation: Promise<unknown> = 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<T>(run: () => Promise<T>): Promise<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
37
src/app/ai/chat/history/document.ts
Normal file
37
src/app/ai/chat/history/document.ts
Normal file
|
|
@ -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<FileSystemFileHandle, string>()
|
||||
|
||||
export async function resolveChatDocumentId(
|
||||
editor: ChatDocumentEditor,
|
||||
store: ConversationStore
|
||||
): Promise<string> {
|
||||
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()}`
|
||||
}
|
||||
154
src/app/ai/chat/history/idb.ts
Normal file
154
src/app/ai/chat/history/idb.ts
Normal file
|
|
@ -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<ChatDatabase>({
|
||||
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<typeof openAppDatabase<ChatDatabase>> | 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
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/app/ai/chat/history/messages.ts
Normal file
44
src/app/ai/chat/history/messages.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
55
src/app/ai/chat/history/persistence.ts
Normal file
55
src/app/ai/chat/history/persistence.ts
Normal file
|
|
@ -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<Conversation | null>
|
||||
messages: Ref<UIMessage[]>
|
||||
readOnly: Ref<boolean>
|
||||
content(): { messages: UIMessage[]; interrupted: boolean }
|
||||
refresh(): Promise<void>
|
||||
}
|
||||
|
||||
/** Captures snapshots before queuing writes; failures remain visible and retryable. */
|
||||
export function createHistoryPersistence(options: PersistenceOptions) {
|
||||
const storageError = ref(false)
|
||||
let writes: Promise<void> = Promise.resolve()
|
||||
|
||||
function flush(): Promise<void> {
|
||||
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 }
|
||||
}
|
||||
63
src/app/ai/chat/history/session.ts
Normal file
63
src/app/ai/chat/history/session.ts
Normal file
|
|
@ -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<void>) {
|
||||
let live: HistoryChat | null = null
|
||||
let interrupted = false
|
||||
let generation = 0
|
||||
let stopWatch: (() => void) | undefined
|
||||
|
||||
function attach(next: HistoryChat, flush: () => Promise<void>): 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<void>): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/app/ai/chat/history/types.ts
Normal file
51
src/app/ai/chat/history/types.ts
Normal file
|
|
@ -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<Chat<UIMessage>, 'messages' | 'status' | 'stop'>
|
||||
|
||||
export interface HistoryRuntime<TChat extends HistoryChat> {
|
||||
getEditor(): ChatDocumentEditor
|
||||
ensureChat(messages?: UIMessage[], sessionId?: string): Promise<TChat | null>
|
||||
resetChat(): Promise<void>
|
||||
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<ConversationMeta[]>
|
||||
read(id: string): Promise<Conversation | null>
|
||||
write(conversation: Conversation): Promise<void>
|
||||
rename(id: string, title: string): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
resolveFile(handle: FileSystemFileHandle): Promise<string>
|
||||
reassignDocument(from: string, to: string, name: string): Promise<void>
|
||||
getSelected(documentId: string): Promise<string | null>
|
||||
select(documentId: string, id: string): Promise<void>
|
||||
}
|
||||
10
src/app/ai/chat/preferences.ts
Normal file
10
src/app/ai/chat/preferences.ts
Normal file
|
|
@ -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 } }
|
||||
}
|
||||
})
|
||||
|
|
@ -29,6 +29,7 @@ interface SubmissionMessages {
|
|||
interface SubmissionOptions {
|
||||
chat: Ref<Chat<UIMessage> | null>
|
||||
ensureChat: () => Promise<Chat<UIMessage> | null>
|
||||
flush?: () => Promise<void>
|
||||
clearFailure: () => void
|
||||
getEditor: () => EditorStore
|
||||
messages: Ref<SubmissionMessages>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof getActiveEditorStore>
|
||||
|
||||
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<UIMessage>
|
||||
return resumableTransport(
|
||||
new DirectChatTransport({
|
||||
agent,
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
return 'The provider rejected the request.'
|
||||
}
|
||||
}) as ChatTransport<UIMessage>
|
||||
)
|
||||
}
|
||||
|
||||
export function createChatSessionManager({
|
||||
|
|
@ -137,10 +141,10 @@ export function createChatSessionManager({
|
|||
const failure = ref<AIChatFailure | null>(null)
|
||||
let transportDirty = false
|
||||
let currentChatStore: EditorStore | null = null
|
||||
let currentChatMessages = new WeakMap<EditorStore, UIMessage[]>()
|
||||
const currentChatMessages = new WeakMap<EditorStore, UIMessage[]>()
|
||||
let chat: Chat<UIMessage> | null = null
|
||||
let acpTransportInstance: { destroy(): Promise<void> } | null = null
|
||||
let harnessTransportInstance: { destroy(): Promise<void> } | null = null
|
||||
let harnessTransportInstance: { stop(): Promise<void> } | null = null
|
||||
let overrideTransport: (() => ChatTransport<UIMessage>) | 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<void> {
|
||||
|
|
@ -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<UIMessage>
|
||||
}
|
||||
|
||||
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<Chat<UIMessage> | null> {
|
||||
async function ensureChat(
|
||||
initialMessages?: UIMessage[],
|
||||
sessionId = crypto.randomUUID()
|
||||
): Promise<Chat<UIMessage> | 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<UIMessage>
|
||||
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<UIMessage>({
|
||||
transport,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
|
|||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly configuration: HarnessSessionConfiguration,
|
||||
private readonly environment: Record<string, string>
|
||||
private readonly environment: Record<string, string>,
|
||||
private readonly spawnProcess: typeof spawnHarnessProcess = spawnHarnessProcess
|
||||
) {}
|
||||
|
||||
async sendMessages({
|
||||
|
|
@ -135,6 +136,19 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
|
|||
return null
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
if (this.destroyed) return
|
||||
this.destroyed = true
|
||||
|
|
@ -190,7 +204,7 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
|
|||
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'))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AppPreferences> = {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import { refAutoReset, useClipboard } from '@vueuse/core'
|
||||
import { computed, markRaw, shallowRef, nextTick, ref, watch } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { getACPDebugText, clearACPDebugLog, hasACPDebugEntries } from '@/app/ai/acp/transport'
|
||||
import { getACPDebugText, hasACPDebugEntries } from '@/app/ai/acp/transport'
|
||||
import { copyChatLog } from '@/app/ai/debug'
|
||||
import { clearVisibleMessageText } from '@/app/ai/chat/presentation'
|
||||
import { useChatSubmission } from '@/app/ai/chat/submission/use'
|
||||
import { clearMessageAttachments } from '@/app/ai/attachment/presentation/store'
|
||||
import { clearToolLogEntries, didHitStepLimit } from '@/app/ai/tools'
|
||||
import { didHitStepLimit } from '@/app/ai/tools'
|
||||
import { activeTab } from '@/app/tabs'
|
||||
import { getActiveEditorStore } from '@/app/editor/active-store'
|
||||
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
|
||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||
import AppPlaceholder from '@/components/ui/feedback/AppPlaceholder.vue'
|
||||
import AppButton from '@/components/ui/button/AppButton.vue'
|
||||
import ChatHistory from '@/components/chat/ChatHistory.vue'
|
||||
import { chatDocumentId } from '@/app/ai/chat/history/document'
|
||||
import ChatTranscript from '@/components/chat/ChatTranscript.vue'
|
||||
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
|
||||
import { useAIChat } from '@/app/ai/chat/use'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
|
|
@ -26,11 +23,10 @@ import { useNotificationMessages } from '@/app/i18n/notifications'
|
|||
|
||||
import type { Chat } from '@ai-sdk/vue'
|
||||
import type { UIMessage } from 'ai'
|
||||
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
const IS_DEV = import.meta.env.DEV
|
||||
|
||||
const { isConfigured, ensureChat, resetChat, chatFailure, clearChatFailure } = useAIChat()
|
||||
const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat()
|
||||
const { copy } = useClipboard()
|
||||
const { ai } = useI18n()
|
||||
const notifications = useNotificationMessages()
|
||||
|
|
@ -39,6 +35,7 @@ const chat = shallowRef<Chat<UIMessage> | null>(null)
|
|||
const submission = useChatSubmission({
|
||||
chat,
|
||||
ensureChat,
|
||||
flush: history.flush,
|
||||
clearFailure: clearChatFailure,
|
||||
getEditor: getActiveEditorStore,
|
||||
messages: computed(() => ({
|
||||
|
|
@ -50,9 +47,11 @@ const submission = useChatSubmission({
|
|||
openModelSettings: () => openSettingsDialog('ai')
|
||||
})
|
||||
|
||||
let viewGeneration = 0
|
||||
const initialGeneration = ++viewGeneration
|
||||
void ensureChat()
|
||||
.then((c) => {
|
||||
if (c) chat.value = markRaw(c)
|
||||
if (c && initialGeneration === viewGeneration) chat.value = markRaw(c)
|
||||
return undefined
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
|
@ -62,11 +61,43 @@ void ensureChat()
|
|||
})
|
||||
)
|
||||
})
|
||||
const messagesEnd = ref<HTMLDivElement>()
|
||||
const debugCopied = refAutoReset(false, 1500)
|
||||
const acpLogCopied = refAutoReset(false, 1500)
|
||||
|
||||
const messages = computed(() => chat.value?.messages ?? [])
|
||||
const messages = computed(() => chat.value?.messages ?? history.messages.value)
|
||||
const historyOptions = computed(() => {
|
||||
const current = history.current.value
|
||||
const rows = [...history.conversations.value]
|
||||
if (current && !rows.some((row) => row.id === current.id)) rows.unshift(current)
|
||||
return rows.map((conversation) => ({
|
||||
...conversation,
|
||||
available: conversation.documentId === chatDocumentId(getActiveEditorStore())
|
||||
}))
|
||||
})
|
||||
const agentHistoryReadOnly = computed(
|
||||
() => !chat.value && messages.value.length > 0 && history.current.value?.backend !== 'direct'
|
||||
)
|
||||
async function historyAction(action: () => Promise<unknown>) {
|
||||
const generation = ++viewGeneration
|
||||
submission.cancel()
|
||||
try {
|
||||
await chat.value?.stop()
|
||||
await action()
|
||||
if (generation !== viewGeneration) return
|
||||
chat.value = null
|
||||
const next = await ensureChat()
|
||||
if (generation === viewGeneration) chat.value = next ? markRaw(next) : null
|
||||
} catch {
|
||||
toast.error(ai.value.chatHistoryFailed)
|
||||
}
|
||||
}
|
||||
|
||||
async function renameConversation(id: string, title: string) {
|
||||
try {
|
||||
await history.rename(id, title)
|
||||
} catch {
|
||||
toast.error(ai.value.chatHistoryFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = computed(() => {
|
||||
switch (chatFailure.value?.reason) {
|
||||
case 'authentication':
|
||||
|
|
@ -93,42 +124,14 @@ const failureHasSettingsAction = computed(() =>
|
|||
['authentication', 'forbidden', 'model-not-found'].includes(chatFailure.value?.reason ?? '')
|
||||
)
|
||||
const status = computed(() => chat.value?.status ?? 'ready')
|
||||
function isStreamingMessage(message: UIMessage, index: number): boolean {
|
||||
return (
|
||||
message.role === 'assistant' &&
|
||||
index === messages.value.length - 1 &&
|
||||
(status.value === 'submitted' || status.value === 'streaming')
|
||||
)
|
||||
}
|
||||
const isThinking = computed(() => {
|
||||
const s = status.value
|
||||
if (s !== 'submitted' && s !== 'streaming') return false
|
||||
if (messages.value.length === 0) return true
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last.role !== 'assistant') return true
|
||||
const parts = last.parts
|
||||
if (parts.length === 0) return true
|
||||
const lastPart = parts[parts.length - 1] as JSONObject
|
||||
if (lastPart.type === 'step-start') return true
|
||||
if ('toolCallId' in lastPart && lastPart.state === 'output-available') return true
|
||||
if ('toolCallId' in lastPart && lastPart.state === 'output-error') return true
|
||||
return s === 'submitted'
|
||||
})
|
||||
|
||||
const showContinue = computed(() => {
|
||||
if (history.readOnly.value || agentHistoryReadOnly.value) return false
|
||||
if (status.value !== 'ready') return false
|
||||
if (messages.value.length === 0) return false
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
return last.role === 'assistant' && didHitStepLimit()
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
|
||||
watch(messages, scrollToBottom, { deep: true })
|
||||
watch(
|
||||
() => chatFailure.value?.reason,
|
||||
(reason) => {
|
||||
|
|
@ -145,13 +148,24 @@ watch(
|
|||
}
|
||||
)
|
||||
watch(
|
||||
() => activeTab.value?.id,
|
||||
async () => {
|
||||
() => [activeTab.value?.id, activeTab.value?.store.state.preparation] as const,
|
||||
async ([, preparation]) => {
|
||||
if (preparation) {
|
||||
viewGeneration++
|
||||
submission.cancel()
|
||||
return
|
||||
}
|
||||
const generation = ++viewGeneration
|
||||
submission.cancel()
|
||||
clearMessageAttachments()
|
||||
clearVisibleMessageText()
|
||||
const nextChat = await ensureChat()
|
||||
chat.value = nextChat ? markRaw(nextChat) : null
|
||||
chat.value = null
|
||||
try {
|
||||
await history.initialize()
|
||||
if (generation !== viewGeneration) return
|
||||
const nextChat = await ensureChat()
|
||||
if (generation === viewGeneration) chat.value = nextChat ? markRaw(nextChat) : null
|
||||
} catch {
|
||||
if (generation === viewGeneration) toast.error(ai.value.chatHistoryFailed)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -159,138 +173,83 @@ function handleStop() {
|
|||
submission.stop()
|
||||
}
|
||||
|
||||
const diagnosticNotice = ref('')
|
||||
async function copyDiagnostics(operation: () => Promise<void>) {
|
||||
diagnosticNotice.value = ''
|
||||
try {
|
||||
await operation()
|
||||
diagnosticNotice.value = ai.value.diagnosticCopied
|
||||
} catch {
|
||||
diagnosticNotice.value = ai.value.diagnosticCopyFailed
|
||||
}
|
||||
}
|
||||
async function handleCopyDebug() {
|
||||
await copyChatLog(messages.value, chatFailure.value)
|
||||
debugCopied.value = true
|
||||
await copyDiagnostics(() => copyChatLog(messages.value, chatFailure.value))
|
||||
}
|
||||
|
||||
async function handleCopyACPLog() {
|
||||
const text = getACPDebugText()
|
||||
if (!text) return
|
||||
await copy(text)
|
||||
acpLogCopied.value = true
|
||||
}
|
||||
|
||||
function handleClearChat() {
|
||||
submission.cancel()
|
||||
clearChatFailure()
|
||||
clearMessageAttachments()
|
||||
clearVisibleMessageText()
|
||||
chat.value = null
|
||||
void resetChat().catch((error: unknown) => {
|
||||
console.error('Chat reset error:', error)
|
||||
})
|
||||
clearToolLogEntries()
|
||||
clearACPDebugLog()
|
||||
await copyDiagnostics(() => copy(text))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden select-text">
|
||||
<ChatHistory
|
||||
:saved="history.conversations.value.some((row) => row.id === history.current.value?.id)"
|
||||
:debug="true"
|
||||
:acp-debug="IS_DEV && hasACPDebugEntries()"
|
||||
@copy-debug="handleCopyDebug"
|
||||
@copy-a-c-p-debug="handleCopyACPLog"
|
||||
:conversations="historyOptions"
|
||||
:selected-id="history.current.value?.id"
|
||||
:disabled="history.busy.value"
|
||||
@create="historyAction(history.newChat)"
|
||||
@select="historyAction(() => history.open($event))"
|
||||
@rename="renameConversation"
|
||||
@delete="historyAction(() => history.remove($event))"
|
||||
/>
|
||||
<p v-if="diagnosticNotice" role="status" class="px-3 py-2 text-xs text-muted">
|
||||
{{ diagnosticNotice }}
|
||||
</p>
|
||||
<p v-if="history.storageError.value" role="alert" class="px-3 py-2 text-xs text-red-400">
|
||||
{{ ai.chatStorageFailed }}
|
||||
</p>
|
||||
<ProviderSetup v-if="!isConfigured" />
|
||||
|
||||
<template v-else>
|
||||
<ScrollAreaRoot class="min-h-0 flex-1">
|
||||
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
|
||||
<AppPlaceholder
|
||||
v-if="messages.length === 0"
|
||||
data-test-id="chat-empty-state"
|
||||
:label="ai.describeCreateOrChange"
|
||||
:ui="{ root: 'h-full' }"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-message-circle class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
|
||||
<ChatMessage
|
||||
v-for="(msg, index) in messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:streaming="isStreamingMessage(msg, index)"
|
||||
/>
|
||||
|
||||
<!-- Thinking indicator: shown when AI is working but no visible activity -->
|
||||
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
|
||||
<div
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
<div class="flex items-center gap-1 py-2">
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 0ms"
|
||||
/>
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 150ms"
|
||||
/>
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 300ms"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue button when step limit reached -->
|
||||
<div v-if="showContinue" class="flex justify-center py-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-full bg-accent/10 px-4 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
|
||||
@click="
|
||||
submission.submit({
|
||||
modelText: 'Continue where you left off',
|
||||
displayText: 'Continue where you left off',
|
||||
images: [],
|
||||
nodes: []
|
||||
})
|
||||
"
|
||||
>
|
||||
<icon-lucide-play class="size-3" />
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="messagesEnd" />
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
<!-- Chat toolbar -->
|
||||
<div
|
||||
v-if="messages.length > 0"
|
||||
class="flex shrink-0 items-center gap-1 border-t border-border px-3 py-1"
|
||||
<template v-if="isConfigured || messages.length">
|
||||
<p
|
||||
v-if="history.current.value?.interrupted && status === 'ready'"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted"
|
||||
>
|
||||
<AppButton v-if="IS_DEV" color="neutral" variant="ghost" size="xs" @click="handleCopyDebug">
|
||||
<icon-lucide-clipboard-copy v-if="!debugCopied" class="size-3" />
|
||||
<icon-lucide-check v-else class="size-3 text-green-400" />
|
||||
{{ debugCopied ? 'Copied' : 'Copy log' }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-if="IS_DEV && hasACPDebugEntries()"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="handleCopyACPLog"
|
||||
>
|
||||
<icon-lucide-bug v-if="!acpLogCopied" class="size-3" />
|
||||
<icon-lucide-check v-else class="size-3 text-green-400" />
|
||||
{{ acpLogCopied ? 'Copied' : 'ACP log' }}
|
||||
</AppButton>
|
||||
<AppButton color="error" variant="ghost" size="xs" @click="handleClearChat">
|
||||
<icon-lucide-trash-2 class="size-3" />
|
||||
Clear
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
{{ ai.chatInterrupted }}
|
||||
</p>
|
||||
<ChatTranscript
|
||||
:messages="messages"
|
||||
:status="status"
|
||||
:disabled="submission.busy.value"
|
||||
:show-continue="showContinue"
|
||||
@continue="
|
||||
submission.submit({
|
||||
modelText: 'Continue where you left off',
|
||||
displayText: 'Continue where you left off',
|
||||
images: [],
|
||||
nodes: []
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<p v-if="agentHistoryReadOnly" role="status" class="px-3 py-2 text-xs text-muted">
|
||||
{{ ai.chatAgentReadOnly }}
|
||||
</p>
|
||||
<p v-if="history.readOnly.value" role="status" class="px-3 py-2 text-xs text-muted">
|
||||
{{ ai.chatReadOnly }}
|
||||
</p>
|
||||
<ChatInput
|
||||
v-if="isConfigured && !agentHistoryReadOnly && !history.readOnly.value"
|
||||
:status="status"
|
||||
:disabled="submission.busy.value || history.busy.value"
|
||||
@submit="submission.submit"
|
||||
@stop="handleStop"
|
||||
@error="toast.error"
|
||||
|
|
|
|||
97
src/components/chat/ChatComposer.vue
Normal file
97
src/components/chat/ChatComposer.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<script setup lang="ts">
|
||||
import { useTextareaAutosize } from '@vueuse/core'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ChatStatus } from 'ai'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import InputGroup from '@/components/ui/input/InputGroup.vue'
|
||||
const { status, disabled = false } = defineProps<{ status: ChatStatus; disabled?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
submit: [text: string]
|
||||
stop: []
|
||||
settings: []
|
||||
paste: [event: ClipboardEvent]
|
||||
}>()
|
||||
const { ai } = useI18n()
|
||||
const textarea = ref<HTMLTextAreaElement>()
|
||||
const input = ref('')
|
||||
const { triggerResize } = useTextareaAutosize({ element: textarea, input, maxHeight: 160 })
|
||||
const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted')
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
|
||||
event.preventDefault()
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
|
||||
}
|
||||
function handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
if (isStreaming.value) return
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
emit('submit', text)
|
||||
input.value = ''
|
||||
triggerResize()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="shrink-0 border-t border-border p-2.5">
|
||||
<form @submit="handleSubmit" @paste.stop="emit('paste', $event)">
|
||||
<InputGroup :disabled="isStreaming">
|
||||
<template v-if="$slots.attachment" #attachment><slot name="attachment" /></template>
|
||||
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="input"
|
||||
data-test-id="chat-input"
|
||||
:placeholder="ai.describeChange"
|
||||
:disabled="isStreaming"
|
||||
rows="2"
|
||||
:aria-label="ai.describeChange"
|
||||
class="block min-h-12 w-full resize-none overflow-y-auto bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@keydown="handleInputKeydown"
|
||||
@copy.stop
|
||||
@cut.stop
|
||||
/>
|
||||
|
||||
<template #leading><slot name="leading" /></template>
|
||||
|
||||
<template #model><slot name="model" /></template>
|
||||
|
||||
<template #actions>
|
||||
<IconButton
|
||||
:label="ai.providerSettings"
|
||||
size="sm"
|
||||
data-test-id="provider-settings-trigger"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<icon-lucide-settings class="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="isStreaming"
|
||||
:label="ai.stopGenerating"
|
||||
size="sm"
|
||||
data-test-id="chat-stop-button"
|
||||
class="border border-border"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<icon-lucide-square class="size-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else
|
||||
:label="ai.sendMessage"
|
||||
size="sm"
|
||||
type="submit"
|
||||
data-test-id="chat-send-button"
|
||||
class="bg-accent text-white hover:bg-accent/90 hover:text-white"
|
||||
:disabled="!input.trim()"
|
||||
>
|
||||
<icon-lucide-send class="size-3.5" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
226
src/components/chat/ChatHistory.vue
Normal file
226
src/components/chat/ChatHistory.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useFocus } from '@vueuse/core'
|
||||
import {
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
PopoverPortal,
|
||||
PopoverContent,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
import AppButton from '@/components/ui/button/AppButton.vue'
|
||||
import Tip from '@/components/ui/overlay/Tip.vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import iconButtonTheme from '@/theme/button/icon-button'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import { menuItem, useMenuUI } from '@/components/ui/menu/menu'
|
||||
import { chatHistoryTheme } from '@/theme/chat/history'
|
||||
|
||||
const {
|
||||
conversations,
|
||||
selectedId,
|
||||
disabled = false,
|
||||
saved = false,
|
||||
debug = false,
|
||||
acpDebug = false
|
||||
} = defineProps<{
|
||||
conversations: { id: string; title: string; documentName: string; available: boolean }[]
|
||||
selectedId?: string
|
||||
disabled?: boolean
|
||||
saved?: boolean
|
||||
debug?: boolean
|
||||
acpDebug?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
create: []
|
||||
rename: [id: string, title: string]
|
||||
delete: [id: string]
|
||||
copyDebug: []
|
||||
copyACPDebug: []
|
||||
}>()
|
||||
const { ai } = useI18n()
|
||||
const triggerClass = tv(iconButtonTheme)({ size: 'sm' })
|
||||
const styles = chatHistoryTheme()
|
||||
const menu = useMenuUI({ content: 'min-w-44' })
|
||||
const item = menuItem({ justify: 'start' })
|
||||
const open = ref(false)
|
||||
const all = ref(false)
|
||||
const query = ref('')
|
||||
const editing = ref(false)
|
||||
const confirming = ref(false)
|
||||
const title = ref('')
|
||||
const titleInput = ref<HTMLInputElement>()
|
||||
const { focused } = useFocus(titleInput)
|
||||
const selected = computed(() => conversations.find((chat) => chat.id === selectedId))
|
||||
const results = computed(() =>
|
||||
conversations.filter(
|
||||
(chat) =>
|
||||
(all.value || chat.available) &&
|
||||
`${chat.title} ${chat.documentName}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query.value.trim().toLocaleLowerCase())
|
||||
)
|
||||
)
|
||||
watch(
|
||||
() => selectedId,
|
||||
() => {
|
||||
editing.value = false
|
||||
confirming.value = false
|
||||
}
|
||||
)
|
||||
function choose(id: string) {
|
||||
open.value = false
|
||||
emit('select', id)
|
||||
}
|
||||
async function rename() {
|
||||
confirming.value = false
|
||||
title.value = selected.value?.title ?? ''
|
||||
editing.value = true
|
||||
await nextTick()
|
||||
focused.value = true
|
||||
titleInput.value?.select()
|
||||
}
|
||||
function confirmDelete() {
|
||||
editing.value = false
|
||||
confirming.value = true
|
||||
}
|
||||
function remove() {
|
||||
if (selectedId) emit('delete', selectedId)
|
||||
confirming.value = false
|
||||
}
|
||||
function save() {
|
||||
if (selectedId && title.value.trim()) emit('rename', selectedId, title.value)
|
||||
editing.value = false
|
||||
}
|
||||
function closeMenu(event: Event) {
|
||||
if (editing.value) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="styles.root()">
|
||||
<div :class="styles.header()">
|
||||
<PopoverRoot v-model:open="open">
|
||||
<PopoverTrigger :class="styles.trigger()" :disabled="disabled" :aria-label="ai.chatHistory">
|
||||
<span :class="styles.title()">{{ selected?.title || ai.newChat }}</span>
|
||||
<icon-lucide-chevron-down :class="styles.icon()" />
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
:side-offset="6"
|
||||
:class="styles.content()"
|
||||
:aria-label="ai.chatHistory"
|
||||
>
|
||||
<input
|
||||
v-model="query"
|
||||
:aria-label="ai.searchChats"
|
||||
:placeholder="ai.searchChats"
|
||||
:class="styles.input()"
|
||||
/>
|
||||
<div :class="styles.scope()" role="group" :aria-label="ai.chatScope">
|
||||
<button
|
||||
:class="styles.scopeButton()"
|
||||
:data-active="!all"
|
||||
:aria-pressed="!all"
|
||||
@click="all = false"
|
||||
>
|
||||
{{ ai.thisDocument }}
|
||||
</button>
|
||||
<button
|
||||
:class="styles.scopeButton()"
|
||||
:data-active="all"
|
||||
:aria-pressed="all"
|
||||
@click="all = true"
|
||||
>
|
||||
{{ ai.allDocuments }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="styles.list()">
|
||||
<button
|
||||
v-for="chat in results"
|
||||
:key="chat.id"
|
||||
:class="styles.row()"
|
||||
:data-selected="chat.id === selectedId"
|
||||
:aria-current="chat.id === selectedId ? 'true' : undefined"
|
||||
:disabled="disabled"
|
||||
@click="choose(chat.id)"
|
||||
>
|
||||
<span :class="styles.title()"
|
||||
><span :class="styles.label()">{{ chat.title || ai.newChat }}</span
|
||||
><span v-if="all" :class="styles.detail()">{{ chat.documentName }}</span></span
|
||||
>
|
||||
<icon-lucide-check v-if="chat.id === selectedId" :class="styles.icon()" />
|
||||
</button>
|
||||
<p v-if="!results.length" :class="styles.empty()">{{ ai.noChatsFound }}</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
<IconButton :label="ai.newChat" :disabled="disabled" size="sm" @click="emit('create')"
|
||||
><icon-lucide-plus :class="styles.icon()"
|
||||
/></IconButton>
|
||||
<DropdownMenuRoot v-if="saved">
|
||||
<Tip :label="ai.chatActions">
|
||||
<DropdownMenuTrigger
|
||||
:class="triggerClass"
|
||||
:aria-label="ai.chatActions"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<icon-lucide-ellipsis :class="styles.icon()" />
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="6"
|
||||
:class="menu.content"
|
||||
@close-auto-focus="closeMenu"
|
||||
>
|
||||
<DropdownMenuItem :class="item" @select="rename">{{ ai.renameChat }}</DropdownMenuItem>
|
||||
<DropdownMenuItem :class="item" @select="confirmDelete">{{
|
||||
ai.deleteChat
|
||||
}}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="debug" class="my-1 border-t border-border" />
|
||||
<DropdownMenuItem v-if="debug" :class="item" @select="emit('copyDebug')">{{
|
||||
ai.copyDiagnosticLog
|
||||
}}</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="acpDebug" :class="item" @select="emit('copyACPDebug')">{{
|
||||
ai.copyACPLog
|
||||
}}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
<form
|
||||
v-if="editing"
|
||||
:class="styles.form()"
|
||||
@submit.prevent="save"
|
||||
@keydown.esc.stop="editing = false"
|
||||
>
|
||||
<input ref="titleInput" v-model="title" :aria-label="ai.chatTitle" :class="styles.input()" />
|
||||
<div :class="styles.actions()">
|
||||
<AppButton size="xs" @click="editing = false">{{ ai.cancelChatAction }}</AppButton
|
||||
><AppButton size="xs" type="submit" :disabled="disabled || !title.trim()">{{
|
||||
ai.saveChatTitle
|
||||
}}</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="confirming" :class="styles.form()" @keydown.esc.stop="confirming = false">
|
||||
<p :class="styles.confirmation()">{{ ai.deleteChatConfirmation }}</p>
|
||||
<div :class="styles.actions()">
|
||||
<AppButton size="xs" @click="confirming = false">{{ ai.cancelChatAction }}</AppButton
|
||||
><AppButton size="xs" color="error" :disabled="disabled" @click="remove">{{
|
||||
ai.deleteChat
|
||||
}}</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useTextareaAutosize } from '@vueuse/core'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useI18n, useSelectionState } from '@open-pencil/vue'
|
||||
|
||||
|
|
@ -9,7 +7,7 @@ import ChatNodePreview from '@/components/chat/ChatNodePreview.vue'
|
|||
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
|
||||
import { useAttachmentDrafts } from '@/components/chat/input/useAttachments'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import InputGroup from '@/components/ui/input/InputGroup.vue'
|
||||
import ChatComposer from './ChatComposer.vue'
|
||||
import { MAX_IMAGE_ATTACHMENTS } from '@/app/ai/attachment/image/types'
|
||||
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
|
||||
import { useAIChat } from '@/app/ai/chat/use'
|
||||
|
|
@ -33,9 +31,6 @@ const emit = defineEmits<{
|
|||
error: [message: string]
|
||||
}>()
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement>()
|
||||
const input = ref('')
|
||||
const { triggerResize } = useTextareaAutosize({ element: textarea, input, maxHeight: 160 })
|
||||
const attachments = useAttachmentDrafts({
|
||||
editor,
|
||||
selectedIds,
|
||||
|
|
@ -81,159 +76,96 @@ const selectedModelName = computed(() => {
|
|||
const selectedProfileName = computed(
|
||||
() => designModelProfile.value?.name ?? selectedModelName.value
|
||||
)
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
|
||||
event.preventDefault()
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
|
||||
}
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
emit('submit', takeSubmission(text))
|
||||
input.value = ''
|
||||
triggerResize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="shrink-0 border-t border-border p-2.5">
|
||||
<form @submit="handleSubmit" @paste.stop="handlePaste">
|
||||
<InputGroup :disabled="isStreaming">
|
||||
<template v-if="images.length || referencedNodes.length" #attachment>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div
|
||||
v-for="node in referencedNodes"
|
||||
:key="node.id"
|
||||
data-slot="chat-context-chip"
|
||||
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
|
||||
>
|
||||
<ChatNodePreview :editor="editor" :node="node" />
|
||||
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
|
||||
{{ node.name || node.type }}
|
||||
</span>
|
||||
<IconButton
|
||||
:label="ai.removeNodeContext"
|
||||
size="xs"
|
||||
@click="removeReferencedNode(node.id)"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="image.previewURL"
|
||||
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
|
||||
>
|
||||
<img
|
||||
:src="image.previewURL"
|
||||
:alt="image.file.name"
|
||||
width="40"
|
||||
height="40"
|
||||
class="size-10 shrink-0 rounded-md border border-border object-cover"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
|
||||
{{ image.file.name }}
|
||||
</span>
|
||||
<IconButton
|
||||
:label="ai.removeImageAttachment({ name: image.file.name })"
|
||||
size="xs"
|
||||
@click="removeImage(index)"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="input"
|
||||
data-test-id="chat-input"
|
||||
:placeholder="ai.describeChange"
|
||||
:disabled="isStreaming"
|
||||
rows="2"
|
||||
aria-label="Describe a change"
|
||||
class="block min-h-12 w-full resize-none overflow-y-auto bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@keydown="handleInputKeydown"
|
||||
@copy.stop
|
||||
@cut.stop
|
||||
<ChatComposer
|
||||
:status="status"
|
||||
:disabled="disabled"
|
||||
@submit="emit('submit', takeSubmission($event))"
|
||||
@stop="emit('stop')"
|
||||
@paste="handlePaste"
|
||||
@settings="openSettingsDialog('ai')"
|
||||
>
|
||||
<template v-if="images.length || referencedNodes.length" #attachment>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div
|
||||
v-for="node in referencedNodes"
|
||||
:key="node.id"
|
||||
data-slot="chat-context-chip"
|
||||
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
|
||||
>
|
||||
<ChatNodePreview :editor="editor" :node="node" />
|
||||
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
|
||||
{{ node.name || node.type }}
|
||||
</span>
|
||||
<IconButton
|
||||
:label="ai.removeNodeContext"
|
||||
size="xs"
|
||||
@click="removeReferencedNode(node.id)"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="(image, index) in images"
|
||||
:key="image.previewURL"
|
||||
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
|
||||
>
|
||||
<img
|
||||
:src="image.previewURL"
|
||||
:alt="image.file.name"
|
||||
width="40"
|
||||
height="40"
|
||||
class="size-10 shrink-0 rounded-md border border-border object-cover"
|
||||
/>
|
||||
|
||||
<template #leading>
|
||||
<IconButton
|
||||
:label="ai.addSelectionContext"
|
||||
size="sm"
|
||||
:active="selectionContextActive"
|
||||
:disabled="isStreaming || !canAddSelection"
|
||||
data-slot="chat-add-selection-context"
|
||||
@click="toggleCurrentSelection"
|
||||
>
|
||||
<icon-lucide-mouse-pointer-2 class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
:label="ai.attachImages"
|
||||
size="sm"
|
||||
:disabled="isStreaming || images.length >= MAX_IMAGE_ATTACHMENTS"
|
||||
@click="openImageDialog()"
|
||||
>
|
||||
<icon-lucide-image-plus class="size-4" />
|
||||
</IconButton>
|
||||
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
|
||||
{{ image.file.name }}
|
||||
</span>
|
||||
<IconButton
|
||||
:label="ai.removeImageAttachment({ name: image.file.name })"
|
||||
size="xs"
|
||||
@click="removeImage(index)"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #leading>
|
||||
<IconButton
|
||||
:label="ai.addSelectionContext"
|
||||
size="sm"
|
||||
:active="selectionContextActive"
|
||||
:disabled="isStreaming || !canAddSelection"
|
||||
data-slot="chat-add-selection-context"
|
||||
@click="toggleCurrentSelection"
|
||||
>
|
||||
<icon-lucide-mouse-pointer-2 class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
:label="ai.attachImages"
|
||||
size="sm"
|
||||
:disabled="isStreaming || images.length >= MAX_IMAGE_ATTACHMENTS"
|
||||
@click="openImageDialog()"
|
||||
>
|
||||
<icon-lucide-image-plus class="size-4" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #model>
|
||||
<div class="flex min-w-0 items-center">
|
||||
<template v-if="isAgentProvider">
|
||||
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
|
||||
<icon-lucide-bot class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ agentName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ChatProfileSelect v-else>
|
||||
<template #value>
|
||||
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
|
||||
</template>
|
||||
|
||||
<template #model>
|
||||
<div class="flex min-w-0 items-center">
|
||||
<template v-if="isAgentProvider">
|
||||
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
|
||||
<icon-lucide-bot class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ agentName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ChatProfileSelect v-else>
|
||||
<template #value>
|
||||
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
|
||||
</template>
|
||||
</ChatProfileSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<IconButton
|
||||
:label="ai.providerSettings"
|
||||
size="sm"
|
||||
data-test-id="provider-settings-trigger"
|
||||
@click="openSettingsDialog('ai')"
|
||||
>
|
||||
<icon-lucide-settings class="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="isStreaming"
|
||||
:label="ai.stopGenerating"
|
||||
size="sm"
|
||||
data-test-id="chat-stop-button"
|
||||
class="border border-border"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<icon-lucide-square class="size-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else
|
||||
:label="ai.sendMessage"
|
||||
size="sm"
|
||||
type="submit"
|
||||
data-test-id="chat-send-button"
|
||||
class="bg-accent text-white hover:bg-accent/90 hover:text-white"
|
||||
:disabled="!input.trim()"
|
||||
>
|
||||
<icon-lucide-send class="size-3.5" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ChatProfileSelect>
|
||||
</div>
|
||||
</template>
|
||||
</ChatComposer>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -5,23 +5,31 @@ import { isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName } from 'ai'
|
|||
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
|
||||
import { useI18n, vTestId } from '@open-pencil/vue'
|
||||
|
||||
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
|
||||
import { attachmentsForMessage } from '@/app/ai/attachment/presentation/store'
|
||||
import { visibleUserMessageText } from '@/app/ai/chat/presentation'
|
||||
import AttachmentList from '@/components/chat/attachment/AttachmentList.vue'
|
||||
import ChatMarkdown from '@/components/chat/ChatMarkdown.vue'
|
||||
import { reasoningDisplay } from '@/app/ai/chat/preferences'
|
||||
import ReasoningBlock from '@/components/chat/ReasoningBlock.vue'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import { classifyToolState } from './tool-state'
|
||||
|
||||
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
|
||||
|
||||
const { message, streaming = false } = defineProps<{
|
||||
const {
|
||||
message,
|
||||
streaming = false,
|
||||
presentation
|
||||
} = defineProps<{
|
||||
message: UIMessage
|
||||
streaming?: boolean
|
||||
presentation?: { text?: string; attachments?: AttachmentPresentation[] }
|
||||
}>()
|
||||
const { ai } = useI18n()
|
||||
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
|
||||
const attachments = attachmentsForMessage(message.id)
|
||||
const storedAttachments = attachmentsForMessage(message.id)
|
||||
const attachments = computed(() => presentation?.attachments ?? storedAttachments.value)
|
||||
const assistantText = computed(() =>
|
||||
message.parts
|
||||
.filter(isTextUIPart)
|
||||
|
|
@ -87,6 +95,7 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
|
|||
<ReasoningBlock
|
||||
v-if="isReasoningUIPart(part) && part.text"
|
||||
:text="part.text"
|
||||
:display="reasoningDisplay"
|
||||
:streaming="part.state === 'streaming'"
|
||||
:thinking-label="ai.thinking"
|
||||
:reasoning-label="ai.reasoning"
|
||||
|
|
@ -175,6 +184,7 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
|
|||
class="rounded-xl rounded-br-md bg-accent px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap text-white"
|
||||
>
|
||||
{{
|
||||
presentation?.text ??
|
||||
visibleUserMessageText(
|
||||
message.id,
|
||||
message.parts
|
||||
|
|
|
|||
120
src/components/chat/ChatPlayground.stories.ts
Normal file
120
src/components/chat/ChatPlayground.stories.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
import { expect, userEvent, within } from 'storybook/test'
|
||||
|
||||
import AppButton from '@/components/ui/button/AppButton.vue'
|
||||
import AppSelect from '@/components/ui/select/AppSelect.vue'
|
||||
|
||||
import ChatComposer from './ChatComposer.vue'
|
||||
import ChatHistory from './ChatHistory.vue'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
import { useConversations } from './stories/useConversations'
|
||||
|
||||
interface Args {
|
||||
initialChat: string
|
||||
narrow: boolean
|
||||
}
|
||||
const meta = {
|
||||
title: 'Chat/Playground',
|
||||
args: { initialChat: 'dashboard', narrow: false },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'The real transcript and composer, composed with isolated story fixtures. Controls above the panel are test controls, not the proposed history UI. No credentials, model calls, document edits, or persistence.'
|
||||
}
|
||||
}
|
||||
},
|
||||
render: (args) => ({
|
||||
components: {
|
||||
ChatHistory,
|
||||
ChatComposer,
|
||||
ChatTranscript,
|
||||
TooltipProvider,
|
||||
AppSelect,
|
||||
AppButton
|
||||
},
|
||||
setup() {
|
||||
return { args, ...useConversations(args.initialChat) }
|
||||
},
|
||||
template: `
|
||||
<TooltipProvider>
|
||||
<div class="max-w-full space-y-3">
|
||||
<fieldset class="max-w-[420px] space-y-2 rounded border border-border p-3 text-xs">
|
||||
<legend class="px-1 text-muted">Story controls — not saved</legend>
|
||||
<AppSelect v-model="selectedId" label="Test conversation" :options="options" />
|
||||
<div class="flex gap-2">
|
||||
<AppButton size="xs" @click="newChat">New fixture</AppButton>
|
||||
<AppButton size="xs" @click="removeChat">Delete fixture</AppButton>
|
||||
</div>
|
||||
<label v-if="selected" class="flex items-center gap-2">Title
|
||||
<input v-model="selected.title" class="min-w-0 flex-1 rounded border border-border bg-input p-1" />
|
||||
</label>
|
||||
</fieldset>
|
||||
<section aria-label="Chat preview" :data-narrow="args.narrow" class="flex h-[620px] w-[420px] max-w-full flex-col overflow-hidden rounded-xl border border-border bg-panel data-[narrow=true]:w-[300px]">
|
||||
<ChatHistory
|
||||
:debug="true"
|
||||
:saved="hasSavedConversation"
|
||||
:conversations="conversationOptions"
|
||||
:selected-id="selectedId"
|
||||
@select="selectConversation"
|
||||
@create="newChat"
|
||||
@rename="renameChat"
|
||||
@delete="removeChat"
|
||||
@copy-debug="copyDiagnostics"
|
||||
/>
|
||||
<ChatTranscript
|
||||
v-if="selected"
|
||||
:key="selected.id"
|
||||
:messages="selected.messages"
|
||||
:presentations="presentations"
|
||||
:status="selected.status"
|
||||
/>
|
||||
<p v-if="notice" role="status" class="px-3 py-2 text-xs text-muted">{{ notice }}</p>
|
||||
<ChatComposer
|
||||
:key="selectedId"
|
||||
:status="selected?.status ?? 'ready'"
|
||||
@submit="submit"
|
||||
@stop="stop"
|
||||
@settings="showProfileNotice"
|
||||
>
|
||||
<template #model>
|
||||
<AppSelect
|
||||
v-model="profile"
|
||||
label="Mock model profile"
|
||||
:options="profileOptions"
|
||||
/>
|
||||
</template>
|
||||
</ChatComposer>
|
||||
</section>
|
||||
</div>
|
||||
</TooltipProvider>`
|
||||
})
|
||||
} satisfies Meta<Args>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
export const Conversations: Story = {}
|
||||
export const Interaction: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'New fixture' }))
|
||||
await userEvent.type(
|
||||
canvas.getByRole('textbox', { name: 'Describe a change' }),
|
||||
'Make a dashboard'
|
||||
)
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Send message' }))
|
||||
await expect(canvas.getByText(/local preview response/)).toBeVisible()
|
||||
const title = canvas.getByRole('textbox', { name: 'Title' })
|
||||
await expect(title).toHaveValue('Make a dashboard')
|
||||
await userEvent.clear(title)
|
||||
await userEvent.type(title, 'My dashboard')
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Delete fixture' }))
|
||||
await expect(title).toHaveValue('Monthly expense dashboard')
|
||||
await expect(canvas.getByRole('button', { name: 'Create Frame Done' })).toBeVisible()
|
||||
}
|
||||
}
|
||||
export const Empty: Story = { args: { initialChat: 'empty' } }
|
||||
export const Streaming: Story = { args: { initialChat: 'streaming' } }
|
||||
export const ToolError: Story = { args: { initialChat: 'error' } }
|
||||
export const Narrow: Story = { args: { narrow: true } }
|
||||
export const LongTitle: Story = { args: { initialChat: 'long-title', narrow: true } }
|
||||
113
src/components/chat/ChatTranscript.vue
Normal file
113
src/components/chat/ChatTranscript.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useScrollFollowing } from './transcript/useScrollFollowing'
|
||||
import IconButton from '@/components/ui/button/IconButton.vue'
|
||||
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import type { ChatStatus, UIMessage } from 'ai'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
|
||||
import ChatMessage from './ChatMessage.vue'
|
||||
import AppPlaceholder from '@/components/ui/feedback/AppPlaceholder.vue'
|
||||
|
||||
const {
|
||||
messages,
|
||||
status,
|
||||
showContinue = false
|
||||
} = defineProps<{
|
||||
messages: UIMessage[]
|
||||
status: ChatStatus
|
||||
showContinue?: boolean
|
||||
presentations?: Record<string, { text?: string; attachments?: AttachmentPresentation[] }>
|
||||
}>()
|
||||
const emit = defineEmits<{ continue: [] }>()
|
||||
const { ai } = useI18n()
|
||||
const running = computed(() => status === 'submitted' || status === 'streaming')
|
||||
const isThinking = computed(() => {
|
||||
if (!running.value) return false
|
||||
const last = messages.at(-1)
|
||||
if (!last || last.role !== 'assistant') return true
|
||||
const part = last.parts.at(-1)
|
||||
if (!part || part.type === 'step-start') return true
|
||||
if ('toolCallId' in part && (part.state === 'output-available' || part.state === 'output-error'))
|
||||
return true
|
||||
return status === 'submitted'
|
||||
})
|
||||
const transcriptContent = ref<HTMLDivElement>()
|
||||
const viewportComponent = ref<{ viewportElement?: HTMLElement }>()
|
||||
const viewport = computed(() => viewportComponent.value?.viewportElement)
|
||||
const { arrivedState, resumeFollowing } = useScrollFollowing(
|
||||
viewport,
|
||||
transcriptContent,
|
||||
computed(() => status === 'submitted')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot class="relative min-h-0 flex-1">
|
||||
<ScrollAreaViewport ref="viewportComponent" class="h-full px-3 py-3 [&>div]:h-full">
|
||||
<AppPlaceholder
|
||||
v-if="messages.length === 0"
|
||||
data-test-id="chat-empty-state"
|
||||
:label="ai.describeCreateOrChange"
|
||||
:ui="{ root: 'h-full' }"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-message-circle class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-else ref="transcriptContent" data-test-id="chat-messages" class="flex flex-col gap-3">
|
||||
<ChatMessage
|
||||
v-for="(msg, index) in messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:presentation="presentations?.[msg.id]"
|
||||
:streaming="running && msg.role === 'assistant' && index === messages.length - 1"
|
||||
/>
|
||||
|
||||
<!-- Thinking indicator: shown when AI is working but no visible activity -->
|
||||
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
|
||||
<div
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
<div class="flex items-center gap-1 py-2">
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-muted" />
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
:style="{ animationDelay: '150ms' }"
|
||||
/>
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
:style="{ animationDelay: '300ms' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue button when step limit reached -->
|
||||
<div v-if="showContinue" class="flex justify-center py-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-full bg-accent/10 px-4 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
|
||||
@click="emit('continue')"
|
||||
>
|
||||
<icon-lucide-play class="size-3" />
|
||||
{{ ai.continueChat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
|
||||
</ScrollAreaScrollbar>
|
||||
<IconButton
|
||||
v-if="messages.length && !arrivedState.bottom"
|
||||
:label="ai.jumpToLatest"
|
||||
class="absolute right-3 bottom-3 border border-border bg-panel shadow-sm"
|
||||
@click="resumeFollowing"
|
||||
><icon-lucide-arrow-down class="size-4"
|
||||
/></IconButton>
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
35
src/components/chat/ReasoningBlock.stories.ts
Normal file
35
src/components/chat/ReasoningBlock.stories.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { ReasoningDisplay } from '@/app/settings/preferences/store'
|
||||
|
||||
import ReasoningBlock from './ReasoningBlock.vue'
|
||||
|
||||
interface Args {
|
||||
text: string
|
||||
thinkingLabel: string
|
||||
reasoningLabel: string
|
||||
streaming: boolean
|
||||
display: ReasoningDisplay
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Chat/Reasoning',
|
||||
component: ReasoningBlock,
|
||||
args: {
|
||||
text: 'I’ll inspect the selected frame, compare its spacing, and arrange the summary cards.\n\nThe layout should keep a consistent **24 px gap**.',
|
||||
thinkingLabel: 'Thinking…',
|
||||
reasoningLabel: 'Reasoning',
|
||||
streaming: false,
|
||||
display: 'collapsed'
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { ReasoningBlock },
|
||||
setup: () => ({ args }),
|
||||
template: '<div class="w-80"><ReasoningBlock v-bind="args" /></div>'
|
||||
})
|
||||
} satisfies Meta<Args>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
export const Collapsed: Story = {}
|
||||
export const Expanded: Story = { args: { display: 'expanded' } }
|
||||
export const WhileThinking: Story = { args: { display: 'while-thinking', streaming: true } }
|
||||
|
|
@ -3,26 +3,29 @@ import { useTimeoutFn } from '@vueuse/core'
|
|||
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ReasoningDisplay } from '@/app/settings/preferences/store'
|
||||
import ChatMarkdown from '@/components/chat/ChatMarkdown.vue'
|
||||
|
||||
const {
|
||||
display = 'collapsed',
|
||||
text,
|
||||
streaming = false,
|
||||
thinkingLabel,
|
||||
reasoningLabel
|
||||
} = defineProps<{
|
||||
display?: ReasoningDisplay
|
||||
text: string
|
||||
streaming?: boolean
|
||||
thinkingLabel: string
|
||||
reasoningLabel: string
|
||||
}>()
|
||||
|
||||
const open = ref(streaming)
|
||||
const open = ref(display === 'expanded' || (display === 'while-thinking' && streaming))
|
||||
const userChangedOpen = ref(false)
|
||||
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
|
||||
const { start: scheduleClose, stop: cancelClose } = useTimeoutFn(
|
||||
() => {
|
||||
if (!streaming && !userChangedOpen.value) open.value = false
|
||||
if (display === 'while-thinking' && !streaming && !userChangedOpen.value) open.value = false
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
|
|
@ -32,15 +35,24 @@ watch(
|
|||
() => streaming,
|
||||
(isStreaming, wasStreaming) => {
|
||||
cancelClose()
|
||||
if (userChangedOpen.value) return
|
||||
if (isStreaming) {
|
||||
userChangedOpen.value = false
|
||||
open.value = true
|
||||
} else if (wasStreaming) {
|
||||
open.value = display !== 'collapsed'
|
||||
} else if (wasStreaming && display === 'while-thinking') {
|
||||
scheduleClose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => display,
|
||||
() => {
|
||||
cancelClose()
|
||||
if (!userChangedOpen.value)
|
||||
open.value = display === 'expanded' || (display === 'while-thinking' && streaming)
|
||||
}
|
||||
)
|
||||
|
||||
function updateOpen(value: boolean): void {
|
||||
open.value = value
|
||||
userChangedOpen.value = true
|
||||
|
|
@ -51,6 +63,7 @@ function updateOpen(value: boolean): void {
|
|||
<template>
|
||||
<CollapsibleRoot
|
||||
:open="open"
|
||||
:unmount-on-hide="false"
|
||||
class="rounded-lg border border-border bg-canvas"
|
||||
@update:open="updateOpen"
|
||||
>
|
||||
|
|
@ -60,18 +73,24 @@ function updateOpen(value: boolean): void {
|
|||
>
|
||||
<icon-lucide-brain class="size-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span class="flex-1">{{ streaming ? thinkingLabel : reasoningLabel }}</span>
|
||||
<icon-lucide-loader-circle v-if="streaming" class="size-3 animate-spin" aria-hidden="true" />
|
||||
<icon-lucide-loader-circle
|
||||
v-if="streaming"
|
||||
class="size-3 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<icon-lucide-chevron-down
|
||||
v-else
|
||||
class="size-3 transition-transform [[data-state=open]>&]:rotate-180"
|
||||
class="size-3 transition-transform motion-reduce:transition-none [[data-state=open]>&]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
data-slot="chat-reasoning-content"
|
||||
class="data-[state=closed]:collapsible-up data-[state=open]:collapsible-down overflow-hidden border-t border-border px-2 py-1.5 text-[11px] leading-relaxed text-muted"
|
||||
class="motion-safe:data-[state=closed]:animate-collapsible-up motion-safe:data-[state=open]:animate-collapsible-down animation-duration-150 overflow-hidden"
|
||||
>
|
||||
<ChatMarkdown :content="text" :mode="markdownMode" surface="reasoning" />
|
||||
<div class="border-t border-border px-2 py-1.5 text-[11px] leading-relaxed text-muted">
|
||||
<ChatMarkdown :content="text" :mode="markdownMode" surface="reasoning" />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
|
|
|||
120
src/components/chat/stories/fixtures.ts
Normal file
120
src/components/chat/stories/fixtures.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { ChatStatus, UIMessage } from 'ai'
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
status: ChatStatus
|
||||
messages: UIMessage[]
|
||||
}
|
||||
|
||||
export function conversations(): Conversation[] {
|
||||
return [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Monthly expense dashboard',
|
||||
status: 'ready',
|
||||
messages: [
|
||||
{
|
||||
id: 'request',
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', text: 'Build an expense dashboard with summary cards and a chart.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'response',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
text: 'I’ll inspect the frame, then arrange the summary cards and spending chart.',
|
||||
state: 'done'
|
||||
},
|
||||
{
|
||||
type: 'tool-create_frame',
|
||||
toolCallId: 'create-dashboard',
|
||||
state: 'output-available',
|
||||
input: { name: 'Expenses' },
|
||||
output: { id: 'frame-42', name: 'Expenses' }
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Created the **expense dashboard**.\n\n- Three summary cards\n- Monthly spending chart\n- Recent transactions\n\nWould you like a dark variant?'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'streaming',
|
||||
title: 'Explore a warmer color palette',
|
||||
status: 'streaming',
|
||||
messages: [
|
||||
{
|
||||
id: 'palette-request',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'Try a warmer palette, keeping the text accessible.' }]
|
||||
},
|
||||
{
|
||||
id: 'palette-response',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
text: 'Comparing contrast ratios against the cream background…',
|
||||
state: 'streaming'
|
||||
},
|
||||
{
|
||||
type: 'tool-set_fills',
|
||||
toolCallId: 'palette',
|
||||
state: 'input-available',
|
||||
input: { color: '#fef3c7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'error',
|
||||
title: 'Update a layer that no longer exists',
|
||||
status: 'error',
|
||||
messages: [
|
||||
{
|
||||
id: 'error-request',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'Rename the selected layer.' }]
|
||||
},
|
||||
{
|
||||
id: 'error-response',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool-rename_node',
|
||||
toolCallId: 'rename',
|
||||
state: 'output-error',
|
||||
input: { id: 'deleted-layer' },
|
||||
errorText: 'The selected layer no longer exists.'
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'I couldn’t find that layer. Select another layer and try again.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ id: 'empty', title: 'New chat', status: 'ready', messages: [] },
|
||||
{
|
||||
id: 'long-title',
|
||||
title:
|
||||
'Review the complete onboarding flow across desktop and mobile, including accessibility and empty states',
|
||||
status: 'ready',
|
||||
messages: []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const profileOptions = [
|
||||
{ value: 'balanced', label: 'Balanced (mock)' },
|
||||
{ value: 'fast', label: 'Fast (mock)' }
|
||||
]
|
||||
49
src/components/chat/stories/useAttachments.ts
Normal file
49
src/components/chat/stories/useAttachments.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
|
||||
|
||||
export function useAttachments() {
|
||||
const presentations = ref<Record<string, { attachments: AttachmentPresentation[] }>>({})
|
||||
onMounted(() => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 160
|
||||
canvas.height = 100
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
context.fillStyle = '#fef3c7'
|
||||
context.fillRect(0, 0, 160, 100)
|
||||
context.fillStyle = '#9747ff'
|
||||
context.fillRect(12, 12, 40, 24)
|
||||
context.fillRect(60, 12, 40, 24)
|
||||
context.fillRect(108, 12, 40, 24)
|
||||
canvas.toBlob((preview) => {
|
||||
if (!preview) return
|
||||
presentations.value = {
|
||||
request: {
|
||||
attachments: [
|
||||
{
|
||||
id: 'reference',
|
||||
messageId: 'request',
|
||||
kind: 'image',
|
||||
name: 'reference.png',
|
||||
mediaType: 'image/png',
|
||||
originalSize: { x: 160, y: 100 },
|
||||
preview
|
||||
},
|
||||
{
|
||||
id: 'frame',
|
||||
messageId: 'request',
|
||||
kind: 'node',
|
||||
name: 'Dashboard frame',
|
||||
nodeId: 'preview-frame',
|
||||
nodeType: 'FRAME',
|
||||
originalSize: { x: 160, y: 100 },
|
||||
preview
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
return presentations
|
||||
}
|
||||
102
src/components/chat/stories/useConversations.ts
Normal file
102
src/components/chat/stories/useConversations.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { computed, ref } from 'vue'
|
||||
|
||||
import { conversations, profileOptions } from './fixtures'
|
||||
import { useAttachments } from './useAttachments'
|
||||
|
||||
export function useConversations(initialChat: string) {
|
||||
const chats = ref(conversations())
|
||||
const selectedId = ref(initialChat)
|
||||
const selected = computed(() => chats.value.find((chat) => chat.id === selectedId.value))
|
||||
const options = computed(() => chats.value.map((chat) => ({ value: chat.id, label: chat.title })))
|
||||
const hasSavedConversation = computed(() => Boolean(selected.value?.messages.length))
|
||||
const conversationOptions = computed(() =>
|
||||
chats.value.map((chat) => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
documentName: 'Demo document',
|
||||
available: true
|
||||
}))
|
||||
)
|
||||
function selectConversation(id: string) {
|
||||
selectedId.value = id
|
||||
}
|
||||
function copyDiagnostics() {
|
||||
notice.value = 'Diagnostic copying is mocked in this story.'
|
||||
}
|
||||
function showProfileNotice() {
|
||||
notice.value = 'These are mock profiles. Provider settings are unchanged.'
|
||||
}
|
||||
const profile = ref('balanced')
|
||||
const presentations = useAttachments()
|
||||
|
||||
const notice = ref('')
|
||||
function newChat() {
|
||||
const id = crypto.randomUUID()
|
||||
chats.value.push({ id, title: 'New chat', status: 'ready', messages: [] })
|
||||
selectedId.value = id
|
||||
}
|
||||
function removeChat() {
|
||||
chats.value = chats.value.filter((chat) => chat.id !== selectedId.value)
|
||||
const first = chats.value.at(0)
|
||||
if (first) selectedId.value = first.id
|
||||
else newChat()
|
||||
}
|
||||
function renameChat(id: string, title: string) {
|
||||
const chat = chats.value.find((chat) => chat.id === id)
|
||||
if (chat) chat.title = title
|
||||
}
|
||||
function submit(text: string) {
|
||||
const chat = selected.value
|
||||
if (!chat) return
|
||||
if (!chat.messages.length && chat.title === 'New chat') chat.title = text
|
||||
chat.messages.push({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text }]
|
||||
})
|
||||
chat.messages.push({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This is a **local preview response**. No model was called and no document was changed.'
|
||||
}
|
||||
]
|
||||
})
|
||||
chat.status = 'ready'
|
||||
}
|
||||
function stop() {
|
||||
const chat = selected.value
|
||||
if (!chat) return
|
||||
chat.status = 'ready'
|
||||
for (const message of chat.messages) {
|
||||
message.parts = message.parts.map((part) => {
|
||||
if (part.type === 'reasoning') return { ...part, state: 'done' }
|
||||
if ('toolCallId' in part && part.state === 'input-available')
|
||||
return { ...part, state: 'output-error', errorText: 'Stopped in the story.' }
|
||||
return part
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
chats,
|
||||
renameChat,
|
||||
profileOptions,
|
||||
hasSavedConversation,
|
||||
conversationOptions,
|
||||
selectConversation,
|
||||
copyDiagnostics,
|
||||
showProfileNotice,
|
||||
selectedId,
|
||||
selected,
|
||||
options,
|
||||
presentations,
|
||||
profile,
|
||||
notice,
|
||||
newChat,
|
||||
removeChat,
|
||||
submit,
|
||||
stop
|
||||
}
|
||||
}
|
||||
78
src/components/chat/transcript/useScrollFollowing.ts
Normal file
78
src/components/chat/transcript/useScrollFollowing.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useEventListener, useResizeObserver, useScroll } from '@vueuse/core'
|
||||
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
|
||||
/** Output following is a user-controlled mode, not a distance threshold. */
|
||||
export function useScrollFollowing(
|
||||
viewport: Ref<HTMLElement | undefined>,
|
||||
content: Ref<HTMLElement | undefined>,
|
||||
submitted: Ref<boolean>
|
||||
) {
|
||||
const following = ref(true)
|
||||
let frame: number | undefined
|
||||
let userScrolling = false
|
||||
const { arrivedState, measure } = useScroll(viewport, {
|
||||
onScroll: () => {
|
||||
if (userScrolling) following.value = arrivedState.bottom
|
||||
},
|
||||
onStop: () => {
|
||||
userScrolling = false
|
||||
}
|
||||
})
|
||||
|
||||
function scheduleFollow() {
|
||||
if (!following.value || frame !== undefined) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!following.value || !viewport.value) return
|
||||
viewport.value.scrollTop = viewport.value.scrollHeight
|
||||
measure()
|
||||
})
|
||||
}
|
||||
|
||||
function resumeFollowing() {
|
||||
following.value = true
|
||||
userScrolling = false
|
||||
scheduleFollow()
|
||||
}
|
||||
|
||||
function beginUserScroll() {
|
||||
userScrolling = true
|
||||
following.value = false
|
||||
}
|
||||
|
||||
useEventListener(viewport, 'wheel', beginUserScroll, { passive: true })
|
||||
useEventListener(viewport, 'touchmove', beginUserScroll, { passive: true })
|
||||
useEventListener(viewport, 'keydown', (event) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Space'].includes(event.code))
|
||||
beginUserScroll()
|
||||
})
|
||||
// Scrollbar dragging is an explicit navigation gesture; ordinary content clicks are not.
|
||||
useEventListener(viewport, 'pointerdown', (event) => {
|
||||
if (event.target === viewport.value) beginUserScroll()
|
||||
})
|
||||
useEventListener(
|
||||
content,
|
||||
'click',
|
||||
(event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest('[data-slot="chat-reasoning-trigger"]')) {
|
||||
following.value = false
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
useResizeObserver([viewport, content], () => {
|
||||
scheduleFollow()
|
||||
measure()
|
||||
})
|
||||
watch(submitted, (value) => {
|
||||
if (value) resumeFollowing()
|
||||
})
|
||||
watch(viewport, scheduleFollow, { flush: 'post' })
|
||||
onScopeDispose(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
return { following, arrivedState, resumeFollowing }
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import DiagnosticsSettingsPanel from '@/components/settings/diagnostics/Diagnost
|
|||
import GeneralSettingsPanel from '@/components/settings/general/GeneralSettingsPanel.vue'
|
||||
import MCPConnectionsSection from '@/components/settings/mcp/MCPConnectionsSection.vue'
|
||||
import MCPSettingsPanel from '@/components/settings/mcp/MCPSettingsPanel.vue'
|
||||
import ChatSettingsSection from '@/components/settings/chat/ChatSettingsSection.vue'
|
||||
import ModelsPanel from '@/components/settings/models/ModelsPanel.vue'
|
||||
import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeysSection.vue'
|
||||
import UsageSettingsPanel from '@/components/settings/usage/UsageSettingsPanel.vue'
|
||||
|
|
@ -142,6 +143,7 @@ const navigationClass =
|
|||
data-test-id="settings-ai-panel"
|
||||
>
|
||||
<ModelsPanel />
|
||||
<ChatSettingsSection />
|
||||
</section>
|
||||
|
||||
<UsageSettingsPanel v-else-if="settingsDialogSection === 'usage'" />
|
||||
|
|
|
|||
32
src/components/settings/chat/ChatSettingsSection.vue
Normal file
32
src/components/settings/chat/ChatSettingsSection.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
import { reasoningDisplay } from '@/app/ai/chat/preferences'
|
||||
import AppSelect from '@/components/ui/select/AppSelect.vue'
|
||||
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
|
||||
import SettingsSectionHeader from '@/components/settings/layout/SettingsSectionHeader.vue'
|
||||
|
||||
const { ai } = useI18n()
|
||||
const options = computed(() => [
|
||||
{ value: 'collapsed' as const, label: ai.value.reasoningCollapsed },
|
||||
{ value: 'while-thinking' as const, label: ai.value.reasoningWhileThinking },
|
||||
{ value: 'expanded' as const, label: ai.value.reasoningExpanded }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-4 flex flex-col gap-3">
|
||||
<SettingsSectionHeader>{{ ai.chatSettings }}</SettingsSectionHeader>
|
||||
<SettingsGroup>
|
||||
<label class="flex flex-wrap items-center justify-between gap-4 px-3 py-2.5">
|
||||
<span class="text-xs text-surface">{{ ai.reasoningDisplay }}</span>
|
||||
<AppSelect
|
||||
v-model="reasoningDisplay"
|
||||
:label="ai.reasoningDisplay"
|
||||
:options="options"
|
||||
class="w-44"
|
||||
/>
|
||||
</label>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</template>
|
||||
27
src/theme/chat/history.ts
Normal file
27
src/theme/chat/history.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { tv } from 'tailwind-variants'
|
||||
|
||||
export const chatHistoryTheme = tv({
|
||||
slots: {
|
||||
root: 'shrink-0 border-b border-border',
|
||||
header: 'flex min-w-0 items-center gap-1 px-2 py-1.5',
|
||||
trigger:
|
||||
'flex min-w-0 flex-1 items-center gap-1 rounded px-1.5 py-1 text-left text-xs text-surface hover:bg-hover disabled:opacity-50',
|
||||
title: 'min-w-0 flex-1 truncate',
|
||||
icon: 'size-3.5 shrink-0',
|
||||
content:
|
||||
'z-50 w-80 max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-panel p-2 text-surface shadow-lg',
|
||||
input:
|
||||
'w-full rounded border border-border bg-input px-2 py-1.5 text-xs outline-none focus:border-accent',
|
||||
scope: 'my-2 flex gap-1',
|
||||
scopeButton:
|
||||
'flex-1 rounded px-2 py-1 text-[11px] text-muted hover:bg-hover data-[active=true]:bg-hover data-[active=true]:text-surface',
|
||||
list: 'max-h-64 space-y-0.5 overflow-y-auto',
|
||||
row: 'flex w-full items-center gap-2 rounded px-2 py-2 text-left hover:bg-hover focus-visible:outline-accent data-[selected=true]:bg-hover',
|
||||
detail: 'block truncate text-[10px] text-muted',
|
||||
label: 'block truncate text-xs',
|
||||
empty: 'px-2 py-4 text-center text-xs text-muted',
|
||||
form: 'space-y-2 border-t border-border p-2',
|
||||
actions: 'flex justify-end gap-1',
|
||||
confirmation: 'text-xs text-surface'
|
||||
}
|
||||
})
|
||||
|
|
@ -27,6 +27,6 @@ test('Shift+Enter inserts a line break without submitting', async ({ configuredC
|
|||
test('Enter submits and clears input', async ({ configuredChat: chat }) => {
|
||||
await chat.submit('Hello there')
|
||||
|
||||
await expect(chat.page.getByText('Hello there', { exact: true })).toBeVisible()
|
||||
await expect(chat.userMessage()).toHaveText('Hello there')
|
||||
await expect(chat.input).toHaveValue('')
|
||||
})
|
||||
|
|
|
|||
66
tests/e2e/chat/diagnostics.spec.ts
Normal file
66
tests/e2e/chat/diagnostics.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { test, expect } from '#tests/helpers/chat/fixture'
|
||||
|
||||
test('diagnostic menu copies the conversation to the clipboard', async ({
|
||||
configuredChat: chat,
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await chat.submit('Clipboard regression conversation')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Conversation actions' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Copy diagnostic log' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('Diagnostic log copied.')
|
||||
const contents = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(contents).toContain('OPEN PENCIL AI DEBUG LOG')
|
||||
expect(contents).toContain('Clipboard regression conversation')
|
||||
})
|
||||
|
||||
test('clipboard rejection shows failure feedback rather than success', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await chat.submit('Clipboard failure conversation')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator.clipboard, 'writeText', {
|
||||
configurable: true,
|
||||
value: async () => {
|
||||
throw new DOMException('Permission denied', 'NotAllowedError')
|
||||
}
|
||||
})
|
||||
})
|
||||
await page.getByRole('button', { name: 'Conversation actions' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Copy diagnostic log' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('Could not copy the diagnostic log.')
|
||||
await expect(page.getByText('Diagnostic log copied.', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('mobile conversation menu stays attached to its trigger and inside the viewport', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await chat.submit('Mobile menu conversation')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.getByTestId('mobile-ribbon-ai').click()
|
||||
const trigger = page.getByRole('button', { name: 'Conversation actions' })
|
||||
await expect(trigger).toBeVisible()
|
||||
const triggerBox = await trigger.boundingBox()
|
||||
await trigger.click()
|
||||
const menu = page.getByRole('menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await menu.boundingBox()
|
||||
if (!box || !triggerBox) return false
|
||||
return (
|
||||
box.x >= 0 &&
|
||||
box.x + box.width <= 390 &&
|
||||
box.y >= 0 &&
|
||||
box.y + box.height <= 844 &&
|
||||
Math.abs(box.x + box.width - triggerBox.x - triggerBox.width) < 2
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
33
tests/e2e/chat/file-identity.spec.ts
Normal file
33
tests/e2e/chat/file-identity.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('browser file identity survives reopening and distinguishes same-named files', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const ids = await page.evaluate(async () => {
|
||||
const modulePath = '/src/app/ai/chat/history/idb.ts'
|
||||
const { createConversationStore } = await import(modulePath)
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const directoryName = `chat-identity-${crypto.randomUUID()}`
|
||||
const directory = await root.getDirectoryHandle(directoryName, { create: true })
|
||||
try {
|
||||
const firstDirectory = await directory.getDirectoryHandle('first', { create: true })
|
||||
const secondDirectory = await directory.getDirectoryHandle('second', { create: true })
|
||||
const file = await firstDirectory.getFileHandle('design.fig', { create: true })
|
||||
const concurrent = await Promise.all(
|
||||
Array.from({ length: 12 }, () => createConversationStore().resolveFile(file))
|
||||
)
|
||||
const first = concurrent[0]
|
||||
const reopened = await firstDirectory.getFileHandle('design.fig')
|
||||
const second = await createConversationStore().resolveFile(reopened)
|
||||
const other = await secondDirectory.getFileHandle('design.fig', { create: true })
|
||||
const third = await createConversationStore().resolveFile(other)
|
||||
return { first, second, third, concurrent }
|
||||
} finally {
|
||||
await root.removeEntry(directoryName, { recursive: true })
|
||||
}
|
||||
})
|
||||
expect(new Set(ids.concurrent).size).toBe(1)
|
||||
expect(ids.first).toBe(ids.second)
|
||||
expect(ids.third).not.toBe(ids.first)
|
||||
})
|
||||
55
tests/e2e/chat/history.spec.ts
Normal file
55
tests/e2e/chat/history.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { test, expect } from '#tests/helpers/chat/fixture'
|
||||
|
||||
test('multiple conversations preserve messages and manual titles', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await chat.submit('Create the first dashboard')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
|
||||
await expect(chat.input).toBeVisible()
|
||||
await expect(page.getByTestId('chat-message-user')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Conversation actions' })).toHaveCount(0)
|
||||
await expect(page.getByRole('checkbox', { name: 'All chats' })).toHaveCount(0)
|
||||
await chat.submit('Create the second dashboard')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
|
||||
await page.getByRole('textbox', { name: 'Search conversations…' }).fill('first dashboard')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create the second dashboard', exact: true })
|
||||
).toHaveCount(0)
|
||||
await page.getByRole('button', { name: 'Create the first dashboard', exact: true }).click()
|
||||
await expect(chat.userMessage()).toContainText('Create the first dashboard')
|
||||
const actions = page.getByRole('button', { name: 'Conversation actions' })
|
||||
const triggerBox = await actions.boundingBox()
|
||||
await actions.click()
|
||||
const menuBox = await page.getByRole('menu').boundingBox()
|
||||
expect(triggerBox).not.toBeNull()
|
||||
expect(menuBox).not.toBeNull()
|
||||
if (!triggerBox || !menuBox) throw new Error('Missing menu geometry')
|
||||
expect(Math.abs(menuBox.x + menuBox.width - triggerBox.x - triggerBox.width)).toBeLessThan(2)
|
||||
expect(Math.abs(menuBox.y - triggerBox.y - triggerBox.height - 6)).toBeLessThan(2)
|
||||
await expect(page.getByRole('menuitem', { name: 'Copy diagnostic log' })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Rename', exact: true }).click()
|
||||
await page.getByRole('textbox', { name: 'Conversation title' }).fill('First design')
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Conversation history', exact: true })
|
||||
).toContainText('First design')
|
||||
})
|
||||
|
||||
test('saved transcripts remain readable after reload without provider setup', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await chat.submit('A durable conversation')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
|
||||
await page.reload()
|
||||
await chat.chatTab.click()
|
||||
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'All documents', exact: true }).click()
|
||||
await page.getByRole('button', { name: /A durable conversation/ }).click()
|
||||
await expect(chat.userMessage()).toContainText('A durable conversation')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
})
|
||||
91
tests/e2e/chat/reasoning.spec.ts
Normal file
91
tests/e2e/chat/reasoning.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { test, expect } from '#tests/helpers/chat/fixture'
|
||||
import { finishReasoning, installReasoningTransport } from '#tests/helpers/chat/reasoning'
|
||||
|
||||
test('expand while thinking auto-collapses but preserves manual opening', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('provider-settings-trigger').click()
|
||||
await page.getByRole('combobox', { name: 'Reasoning display' }).click()
|
||||
await page.getByRole('option', { name: 'Expand while thinking', exact: true }).click()
|
||||
await page.getByTestId('app-settings-done').click()
|
||||
await installReasoningTransport(page)
|
||||
await chat.submit('Inspect the layout')
|
||||
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]').last()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await finishReasoning(page)
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await chat.submit('Inspect it again')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await trigger.click()
|
||||
await trigger.click()
|
||||
await finishReasoning(page)
|
||||
await expect(chat.assistantMessage()).toContainText('Finished inspecting')
|
||||
await page.waitForTimeout(1200)
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('collapsed reasoning respects reduced motion and does not scroll old content to the bottom', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await chat.submit('Show reasoning')
|
||||
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]').first()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await chat.submit(Array.from({ length: 60 }, (_, i) => `Follow-up line ${i}`).join('\n'))
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
const viewport = page.getByTestId('chat-panel').locator('[data-reka-scroll-area-viewport]')
|
||||
await viewport.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
const content = page.locator('[data-slot="chat-reasoning-content"]').first()
|
||||
expect(await content.evaluate((element) => getComputedStyle(element).animationName)).toBe('none')
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate(
|
||||
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(500)
|
||||
expect(await viewport.evaluate((element) => element.scrollTop)).toBeLessThan(30)
|
||||
await page.getByRole('button', { name: 'Jump to latest' }).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate(
|
||||
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
)
|
||||
)
|
||||
.toBeLessThan(3)
|
||||
})
|
||||
|
||||
test('reasoning preferences use AI settings and persist across reload', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('provider-settings-trigger').click()
|
||||
const display = page.getByRole('combobox', { name: 'Reasoning display' })
|
||||
await expect(display).toContainText('Collapsed by default')
|
||||
await display.click()
|
||||
await page.getByRole('option', { name: 'Expanded by default', exact: true }).click()
|
||||
await page.getByTestId('app-settings-done').click()
|
||||
await chat.submit('Show your reasoning')
|
||||
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="chat-reasoning-content"]')
|
||||
.evaluate((element) => getComputedStyle(element).animationName)
|
||||
)
|
||||
.toBe('collapsible-up')
|
||||
await expect(page.locator('[data-slot="chat-reasoning-content"]')).toBeHidden()
|
||||
await page.reload()
|
||||
await chat.chatTab.click()
|
||||
await page.getByTestId('provider-settings-trigger').click()
|
||||
await expect(display).toContainText('Expanded by default')
|
||||
})
|
||||
30
tests/e2e/chat/recovery.spec.ts
Normal file
30
tests/e2e/chat/recovery.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { test, expect } from '#tests/helpers/chat/fixture'
|
||||
|
||||
test('recovering an unsaved document restores its selected conversation automatically', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
await page.evaluate(async () => {
|
||||
const editor = window.openPencil?.getStore?.()
|
||||
if (!editor) throw new Error('Missing editor')
|
||||
editor.createShape('RECTANGLE', 100, 100, 200, 100)
|
||||
await editor.persistRecoveryNow()
|
||||
})
|
||||
await chat.submit('Conversation belonging to recovered document')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
// Switching conversations forces a durable flush; choose the original before reload.
|
||||
await page.getByRole('button', { name: 'New chat', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
|
||||
await page
|
||||
.getByRole('button', { name: 'Conversation belonging to recovered document', exact: true })
|
||||
.click()
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click()
|
||||
await chat.chatTab.click()
|
||||
await expect(chat.userMessage()).toContainText('Conversation belonging to recovered document')
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Conversation history', exact: true })
|
||||
).toContainText('Conversation belonging to recovered document')
|
||||
})
|
||||
33
tests/e2e/chat/restore.spec.ts
Normal file
33
tests/e2e/chat/restore.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { test, expect } from '#tests/helpers/chat/fixture'
|
||||
|
||||
test('a long restored conversation scrolls to its latest message on mount', async ({
|
||||
configuredChat: chat,
|
||||
page
|
||||
}) => {
|
||||
const text = Array.from({ length: 100 }, (_, index) => `Restored line ${index}`).join('\n')
|
||||
await chat.submit(text)
|
||||
await expect(chat.assistantMessage()).toBeVisible()
|
||||
await page.reload()
|
||||
await chat.chatTab.click()
|
||||
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'All documents', exact: true }).click()
|
||||
await page.getByRole('button', { name: /Restored line 0/ }).click()
|
||||
await chat.designTab.click()
|
||||
await chat.chatTab.click()
|
||||
const viewport = page.getByTestId('chat-panel').locator('[data-reka-scroll-area-viewport]')
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate((element) => ({
|
||||
overflows: element.scrollHeight > element.clientHeight,
|
||||
bottomGap: element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
}))
|
||||
)
|
||||
.toMatchObject({ overflows: true })
|
||||
await expect
|
||||
.poll(() =>
|
||||
viewport.evaluate(
|
||||
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
)
|
||||
)
|
||||
.toBeLessThan(3)
|
||||
})
|
||||
70
tests/engine/app/ai/harness/transport.test.ts
Normal file
70
tests/engine/app/ai/harness/transport.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
|
||||
import type { HarnessSidecarMessage } from '@open-pencil/harness'
|
||||
|
||||
import { HarnessChatTransport } from '@/app/ai/harness/transport'
|
||||
|
||||
test('failed session stop kills the process without deleting resume state', async () => {
|
||||
let output: ReadableStreamDefaultController<HarnessSidecarMessage> | undefined
|
||||
const methods: string[] = []
|
||||
let kills = 0
|
||||
const transport = new HarnessChatTransport(
|
||||
'test-session',
|
||||
{
|
||||
adapter: 'pi',
|
||||
sandbox: 'just-bash',
|
||||
model: 'test',
|
||||
settings: {},
|
||||
instructions: '',
|
||||
mcpServers: []
|
||||
},
|
||||
{},
|
||||
async () => ({
|
||||
child: {
|
||||
write: async () => undefined,
|
||||
kill: async () => {
|
||||
kills++
|
||||
output?.close()
|
||||
}
|
||||
},
|
||||
messages: new ReadableStream({
|
||||
start(controller) {
|
||||
output = controller
|
||||
}
|
||||
}),
|
||||
send: async (request) => {
|
||||
if (
|
||||
!('id' in request) ||
|
||||
!('method' in request) ||
|
||||
typeof request.id !== 'string' ||
|
||||
typeof request.method !== 'string'
|
||||
)
|
||||
throw new Error('Invalid request')
|
||||
methods.push(request.method)
|
||||
output?.enqueue({
|
||||
type: 'response',
|
||||
id: request.id,
|
||||
...(request.method === 'session.stop'
|
||||
? { error: 'Resume storage failed' }
|
||||
: { result: {} })
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
const stream = await transport.sendMessages({
|
||||
chatId: 'test',
|
||||
trigger: 'submit-message',
|
||||
messageId: undefined,
|
||||
messages: [{ id: 'user', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }]
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
while (!(await reader.read()).done) {
|
||||
/* Drain the completed turn. */
|
||||
}
|
||||
await expect(transport.stop()).rejects.toThrow('Resume storage failed')
|
||||
expect(kills).toBe(1)
|
||||
expect(methods).toContain('session.stop')
|
||||
expect(methods).not.toContain('session.destroy')
|
||||
await transport.stop()
|
||||
expect(kills).toBe(1)
|
||||
})
|
||||
|
|
@ -30,6 +30,12 @@ describe('Portless MCP routing', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('preserves a nonstandard HTTPS proxy port', () => {
|
||||
const route = devAutomationRoute('https://chat-history.open-pencil.localhost:1355', 7600)
|
||||
expect(route.browserURL).toBe('wss://chat-history.mcp.open-pencil.localhost:1355')
|
||||
expect(route.corsOrigin).toBe('https://chat-history.open-pencil.localhost:1355')
|
||||
})
|
||||
|
||||
test('rejects unrelated Portless hostnames', () => {
|
||||
expect(() => devAutomationRoute('https://other.localhost', 7600)).toThrow(
|
||||
'Unexpected OpenPencil Portless URL'
|
||||
|
|
|
|||
48
tests/engine/app/chat/history/continuation.test.ts
Normal file
48
tests/engine/app/chat/history/continuation.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
import { resumableTransport } from '@/app/ai/chat/history/continuation'
|
||||
|
||||
test('continuation omits unfinished tool calls without altering the saved transcript', async () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: 'partial',
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'text', text: 'Working on it' },
|
||||
{ type: 'tool-edit', toolCallId: 'pending', state: 'input-available', input: {} },
|
||||
{
|
||||
type: 'tool-read',
|
||||
toolCallId: 'finished',
|
||||
state: 'output-available',
|
||||
input: {},
|
||||
output: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
let sent: UIMessage[] = []
|
||||
const transport = resumableTransport({
|
||||
reconnectToStream: async () => null,
|
||||
sendMessages: async (options) => {
|
||||
sent = options.messages
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
await transport.sendMessages({
|
||||
chatId: 'chat',
|
||||
trigger: 'submit-message',
|
||||
messageId: undefined,
|
||||
messages
|
||||
})
|
||||
expect(sent[0]?.parts).toHaveLength(2)
|
||||
expect(messages[0]?.parts).toHaveLength(3)
|
||||
expect(
|
||||
sent[0]?.parts.some((part) => 'toolCallId' in part && part.toolCallId === 'finished')
|
||||
).toBe(true)
|
||||
})
|
||||
180
tests/engine/app/chat/history/controller.test.ts
Normal file
180
tests/engine/app/chat/history/controller.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import 'fake-indexeddb/auto'
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import type { UIMessage } from 'ai'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { createConversationHistory } from '@/app/ai/chat/history/controller'
|
||||
import type { ChatDocumentEditor } from '@/app/ai/chat/history/document'
|
||||
import { createConversationStore } from '@/app/ai/chat/history/idb'
|
||||
|
||||
function editor(): ChatDocumentEditor {
|
||||
const id = crypto.randomUUID()
|
||||
return {
|
||||
state: { documentName: 'Test document' },
|
||||
getRecoveryId: () => id,
|
||||
getSourceIdentity: () => ({ path: null, handle: null }),
|
||||
getDocumentFilePath: () => null,
|
||||
getStorageBinding: () => null
|
||||
}
|
||||
}
|
||||
|
||||
test('adopting recovery in the same editor loads its selection instead of moving old chats', async () => {
|
||||
const store = createConversationStore()
|
||||
const active = editor()
|
||||
const history = createConversationHistory(
|
||||
{
|
||||
getEditor: () => active,
|
||||
ensureChat: async () => null,
|
||||
profileId: () => null,
|
||||
resetChat: async () => undefined,
|
||||
backend: () => 'direct'
|
||||
},
|
||||
store
|
||||
)
|
||||
await history.initialize()
|
||||
const original = history.current.value
|
||||
if (!original) throw new Error('Missing draft')
|
||||
await history.rename(original.id, 'Existing chat')
|
||||
const recoveryId = crypto.randomUUID()
|
||||
const restoredId = crypto.randomUUID()
|
||||
await store.write({
|
||||
...original,
|
||||
id: restoredId,
|
||||
documentId: `recovery:${recoveryId}`,
|
||||
title: 'Recovered chat',
|
||||
titleSource: 'manual'
|
||||
})
|
||||
await store.select(`recovery:${recoveryId}`, restoredId)
|
||||
active.getRecoveryId = () => recoveryId
|
||||
await history.initialize()
|
||||
expect(history.current.value?.id).toBe(restoredId)
|
||||
expect((await store.read(original.id))?.documentId).toBe(original.documentId)
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
let activeEditor = editor()
|
||||
let failWrites = false
|
||||
let resets = 0
|
||||
const store = createConversationStore()
|
||||
const chat = reactive({
|
||||
messages: [] as UIMessage[],
|
||||
status: 'ready' as const,
|
||||
stop: async () => undefined
|
||||
})
|
||||
const history = createConversationHistory(
|
||||
{
|
||||
getEditor: () => activeEditor,
|
||||
ensureChat: async () => chat,
|
||||
resetChat: async () => {
|
||||
resets++
|
||||
},
|
||||
profileId: () => null,
|
||||
backend: () => 'direct'
|
||||
},
|
||||
{
|
||||
...store,
|
||||
write: async (row) => {
|
||||
if (failWrites) throw new DOMException('Storage full', 'QuotaExceededError')
|
||||
await store.write(row)
|
||||
}
|
||||
}
|
||||
)
|
||||
return {
|
||||
history,
|
||||
store,
|
||||
chat,
|
||||
setFail: (value: boolean) => {
|
||||
failWrites = value
|
||||
},
|
||||
getResets: () => resets,
|
||||
switchEditor: () => {
|
||||
activeEditor = editor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('failed flush keeps the current conversation and blocks switching until retry succeeds', async () => {
|
||||
const f = fixture()
|
||||
await f.history.ensureChat()
|
||||
const id = f.history.current.value?.id
|
||||
f.chat.messages = [
|
||||
{ id: 'message', role: 'user', parts: [{ type: 'text', text: 'Keep this draft' }] }
|
||||
]
|
||||
f.setFail(true)
|
||||
await expect(f.history.newChat()).rejects.toThrow('Storage full')
|
||||
expect(f.history.current.value?.id).toBe(id)
|
||||
expect(f.history.messages.value[0]?.id).toBe('message')
|
||||
expect(f.history.storageError.value).toBe(true)
|
||||
f.setFail(false)
|
||||
await f.history.newChat()
|
||||
expect(f.history.current.value?.id).not.toBe(id)
|
||||
expect(f.history.storageError.value).toBe(false)
|
||||
if (!id) throw new Error('Missing conversation')
|
||||
expect((await f.store.read(id))?.messages[0]?.message.id).toBe('message')
|
||||
})
|
||||
|
||||
test('queued chat switches finish on the last requested conversation without losing transcripts', async () => {
|
||||
const f = fixture()
|
||||
await f.history.initialize()
|
||||
const first = f.history.current.value?.id
|
||||
if (first) await f.history.rename(first, 'First')
|
||||
await f.history.newChat()
|
||||
const second = f.history.current.value?.id
|
||||
if (second) await f.history.rename(second, 'Second')
|
||||
if (!first || !second) throw new Error('Missing conversation')
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) => f.history.open(index % 2 === 0 ? first : second))
|
||||
)
|
||||
expect(f.history.current.value?.id).toBe(second)
|
||||
expect(await f.store.getSelected(f.history.current.value.documentId)).toBe(second)
|
||||
expect(await f.store.read(first)).not.toBeNull()
|
||||
expect(f.history.busy.value).toBe(false)
|
||||
})
|
||||
|
||||
test('opening the panel and repeatedly creating drafts does not persist empty chats', async () => {
|
||||
const f = fixture()
|
||||
await f.history.initialize()
|
||||
const documentId = f.history.current.value?.documentId
|
||||
await f.history.newChat()
|
||||
await f.history.newChat()
|
||||
expect(await f.store.list(documentId)).toEqual([])
|
||||
if (!documentId) throw new Error('Missing document')
|
||||
expect(await f.store.getSelected(documentId)).toBeNull()
|
||||
})
|
||||
|
||||
test('a pending transport is rejected when the active document changes', async () => {
|
||||
let activeEditor = editor()
|
||||
const pending = Promise.withResolvers<{
|
||||
messages: UIMessage[]
|
||||
status: 'ready'
|
||||
stop: () => Promise<void>
|
||||
}>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
let stops = 0
|
||||
const history = createConversationHistory(
|
||||
{
|
||||
getEditor: () => activeEditor,
|
||||
ensureChat: () => {
|
||||
started.resolve(undefined)
|
||||
return pending.promise
|
||||
},
|
||||
profileId: () => null,
|
||||
resetChat: async () => undefined,
|
||||
backend: () => 'direct'
|
||||
},
|
||||
createConversationStore()
|
||||
)
|
||||
const initializing = history.ensureChat()
|
||||
await started.promise
|
||||
activeEditor = editor()
|
||||
pending.resolve({
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
stop: async () => {
|
||||
stops++
|
||||
}
|
||||
})
|
||||
expect(await initializing).toBeNull()
|
||||
expect(stops).toBe(1)
|
||||
})
|
||||
123
tests/engine/app/chat/history/idb.test.ts
Normal file
123
tests/engine/app/chat/history/idb.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'fake-indexeddb/auto'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createConversationStore } from '@/app/ai/chat/history/idb'
|
||||
import type { Conversation } from '@/app/ai/chat/history/types'
|
||||
|
||||
function conversation(): Conversation {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
documentId: crypto.randomUUID(),
|
||||
documentName: 'Dashboard',
|
||||
title: 'Create a dashboard',
|
||||
titleSource: 'fallback',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
profileId: null,
|
||||
backend: 'direct',
|
||||
interrupted: false,
|
||||
messages: [
|
||||
{
|
||||
message: { id: 'user', role: 'user', parts: [{ type: 'text', text: 'Model context' }] },
|
||||
displayText: 'Create a dashboard',
|
||||
attachments: [
|
||||
{
|
||||
id: 'image',
|
||||
messageId: 'user',
|
||||
kind: 'image',
|
||||
name: 'reference.png',
|
||||
mediaType: 'image/png',
|
||||
originalSize: { x: 1, y: 1 },
|
||||
preview: new Blob(['image'], { type: 'image/png' })
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('conversation IndexedDB storage', () => {
|
||||
test('restores messages, presentation, blobs and selected conversation through a new store', async () => {
|
||||
const first = createConversationStore()
|
||||
const row = conversation()
|
||||
await first.write(row)
|
||||
await first.select(row.documentId, row.id)
|
||||
const reopened = createConversationStore()
|
||||
const restored = await reopened.read(row.id)
|
||||
expect(restored?.messages[0].message).toEqual(row.messages[0].message)
|
||||
expect(restored?.messages[0].displayText).toBe('Create a dashboard')
|
||||
expect(await restored?.messages[0].attachments[0].preview.text()).toBe('image')
|
||||
expect(await reopened.getSelected(row.documentId)).toBe(row.id)
|
||||
expect((await reopened.list(row.documentId)).map((meta) => meta.id)).toEqual([row.id])
|
||||
})
|
||||
|
||||
test('manual rename survives a delayed fallback snapshot', async () => {
|
||||
const store = createConversationStore()
|
||||
const row = conversation()
|
||||
await store.write(row)
|
||||
await store.rename(row.id, ' My dashboard ')
|
||||
await store.write(row)
|
||||
expect((await store.read(row.id))?.title).toBe('My dashboard')
|
||||
expect((await store.read(row.id))?.titleSource).toBe('manual')
|
||||
})
|
||||
|
||||
test('selection cannot bind a conversation to the wrong document', async () => {
|
||||
const store = createConversationStore()
|
||||
const row = conversation()
|
||||
await store.write(row)
|
||||
expect(store.select('another-document', row.id)).rejects.toThrow('different document')
|
||||
expect(await store.getSelected('another-document')).toBeNull()
|
||||
})
|
||||
|
||||
test('saving an untitled document moves all chats and its selection atomically', async () => {
|
||||
const store = createConversationStore()
|
||||
const row = conversation()
|
||||
const second = { ...conversation(), documentId: row.documentId }
|
||||
await store.write(row)
|
||||
await store.write(second)
|
||||
await store.select(row.documentId, second.id)
|
||||
const target = `file:${crypto.randomUUID()}`
|
||||
await store.reassignDocument(row.documentId, target, 'Saved document')
|
||||
expect(await store.list(row.documentId)).toEqual([])
|
||||
expect((await store.list(target)).map((meta) => meta.documentName)).toEqual([
|
||||
'Saved document',
|
||||
'Saved document'
|
||||
])
|
||||
await store.write(row)
|
||||
expect((await store.read(row.id))?.documentId).toBe(target)
|
||||
expect(await store.getSelected(target)).toBe(second.id)
|
||||
expect(await store.getSelected(row.documentId)).toBeNull()
|
||||
})
|
||||
|
||||
test('a stale writer cannot resurrect a deleted conversation', async () => {
|
||||
const store = createConversationStore()
|
||||
const staleWriter = createConversationStore()
|
||||
const row = conversation()
|
||||
await store.write(row)
|
||||
await store.remove(row.id)
|
||||
await expect(staleWriter.write(row)).rejects.toThrow('deleted')
|
||||
expect(await store.read(row.id)).toBeNull()
|
||||
})
|
||||
|
||||
test('an older manual title cannot overwrite a newer rename', async () => {
|
||||
const store = createConversationStore()
|
||||
const row = { ...conversation(), title: 'Old title', titleSource: 'manual' as const }
|
||||
await store.write(row)
|
||||
await store.rename(row.id, 'New title')
|
||||
await store.write(row)
|
||||
expect((await store.read(row.id))?.title).toBe('New title')
|
||||
})
|
||||
|
||||
test('deletion removes content and active selection without affecting other chats', async () => {
|
||||
const store = createConversationStore()
|
||||
const row = conversation()
|
||||
const other = { ...conversation(), documentId: row.documentId }
|
||||
await store.write(row)
|
||||
await store.write(other)
|
||||
await store.select(row.documentId, row.id)
|
||||
await store.remove(row.id)
|
||||
expect(await store.read(row.id)).toBeNull()
|
||||
expect(await store.getSelected(row.documentId)).toBeNull()
|
||||
expect((await store.list(row.documentId)).map((meta) => meta.id)).toEqual([other.id])
|
||||
})
|
||||
})
|
||||
45
tests/helpers/chat/reasoning.ts
Normal file
45
tests/helpers/chat/reasoning.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
/** Hold reasoning open until the test explicitly completes the turn. */
|
||||
export async function installReasoningTransport(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.openPencil?.setChatTransport(() => ({
|
||||
async sendMessages() {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: 'start', messageId: crypto.randomUUID() })
|
||||
controller.enqueue({ type: 'reasoning-start', id: 'thinking' })
|
||||
controller.enqueue({
|
||||
type: 'reasoning-delta',
|
||||
id: 'thinking',
|
||||
delta: 'Inspecting the selected layout.'
|
||||
})
|
||||
window.addEventListener(
|
||||
'finish-test-reasoning',
|
||||
() => {
|
||||
controller.enqueue({ type: 'reasoning-end', id: 'thinking' })
|
||||
controller.enqueue({ type: 'text-start', id: 'answer' })
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
id: 'answer',
|
||||
delta: 'Finished inspecting the layout.'
|
||||
})
|
||||
controller.enqueue({ type: 'text-end', id: 'answer' })
|
||||
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
||||
controller.close()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
async reconnectToStream() {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export async function finishReasoning(page: Page): Promise<void> {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('finish-test-reasoning')))
|
||||
}
|
||||
|
|
@ -216,3 +216,6 @@ zh-cn:updates.downloading
|
|||
zh-cn:updates.installed
|
||||
zh-cn:updates.unavailable
|
||||
zh-cn:updates.upToDate
|
||||
|
||||
ja:ai.copyACPLog
|
||||
zh-cn:ai.copyACPLog
|
||||
|
|
|
|||
|
|
@ -230,3 +230,7 @@ zh-cn:panels.maskTypeAlpha
|
|||
zh-cn:storage.providerAmazonS3
|
||||
zh-cn:storage.providerBackblaze
|
||||
zh-cn:storage.providerR2
|
||||
|
||||
de:ai.chatSettings
|
||||
es:ai.chatSettings
|
||||
it:ai.chatSettings
|
||||
|
|
|
|||
Loading…
Reference in a new issue