diff --git a/src/App.jsx b/src/App.jsx index c531a57..286be9e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import TextareaAutosize from 'react-textarea-autosize'; import AssistantMessageContent from './AssistantMessageContent' import ChatDatabasePicker from './ChatDatabasePicker' import LibraryManager from './LibraryManager' +import SaveMessageToKnowledgeDialog from './SaveMessageToKnowledgeDialog' import { SettingsPanel, SettingsSidebar } from './SettingsPanels' import { applyColorScheme } from './colorSchemes' import { @@ -152,6 +153,9 @@ export default function App() { // Editing state for user messages const [editingMessageIndex, setEditingMessageIndex] = useState(null); const [editText, setEditText] = useState(''); + const [knowledgeSaveTarget, setKnowledgeSaveTarget] = useState(null) + const [knowledgeSaveToast, setKnowledgeSaveToast] = useState('') + const knowledgeJumpTargetRef = useRef(null) // Helpers + handlers for message copy/edit/regenerate (must live inside App) function getMarkdownForCopy(message) { const raw = message.content || ''; @@ -835,20 +839,26 @@ export default function App() { applyColorScheme(colorScheme); }, [colorScheme]); - const fetchHistory = (sessionId) => { - if (!sessionId || !backendApiUrl) return; - fetch(`${backendApiUrl}/history?session_id=${encodeURIComponent(sessionId)}`) - .then(r => r.json()) - .then(data => { - setChatSessions(prevSessions => - prevSessions.map(session => - session.session_id === sessionId - ? { ...session, messages: data.messages || [] } - : session - ) - ); - }) - .catch(() => {}); + const fetchHistory = async (sessionId) => { + if (!sessionId || !backendApiUrl) return null; + try { + const response = await fetch(`${backendApiUrl}/history?session_id=${encodeURIComponent(sessionId)}`) + const data = await response.json() + const historyMessages = (data.messages || []).map(message => ({ + ...message, + id: message.message_id || message.id, + })) + setChatSessions(prevSessions => + prevSessions.map(session => + session.session_id === sessionId + ? { ...session, messages: historyMessages } + : session + ) + ); + return historyMessages + } catch { + return null + } }; async function refreshLibraries() { @@ -1011,6 +1021,47 @@ export default function App() { return libraries.find(lib => lib.slug === activeLibrarySlug) || null; }, [activeLibrarySlug, libraries]); + function showKnowledgeSaveToast(message) { + setKnowledgeSaveToast(message) + window.setTimeout(() => setKnowledgeSaveToast(current => current === message ? '' : current), 4200) + } + + function openKnowledgeSaveDialog(message, index) { + if (!message?.message_id) { + showKnowledgeSaveToast('This message is still being written to chat history. Try again in a moment.') + return + } + let precedingUserMessage = null + if (message.role === 'assistant') { + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + if (messages[cursor]?.role === 'user') { + precedingUserMessage = messages[cursor] + break + } + } + } + setKnowledgeSaveTarget({ message, precedingUserMessage }) + } + + async function handleKnowledgeSaved(result) { + setKnowledgeSaveTarget(null) + await refreshLibraries() + await refreshLibraryJobs() + showKnowledgeSaveToast(result?.already_saved ? 'This message is already saved in that database.' : 'Message saved to knowledge.') + } + + function openChatMessageSource(item) { + const sessionId = String(item?.source_session_id || '') + const messageId = String(item?.source_message_id || '') + if (!sessionId || !chatSessions.some(session => session.session_id === sessionId)) { + showKnowledgeSaveToast('The original chat is no longer available. The saved knowledge remains intact.') + return + } + knowledgeJumpTargetRef.current = messageId + setActiveSidebarMode('chats') + selectChat(sessionId) + } + const chatModelPickerOptions = useMemo(() => { return buildModelPickerOptions(availableChatModels, model, 'saved model unavailable') }, [availableChatModels, model]) @@ -1030,6 +1081,7 @@ export default function App() { input, isSending, model, + onMessagesPersisted: fetchHistory, restoredForRef, scrollMessageToTop, scrollToBottom, @@ -1050,6 +1102,14 @@ export default function App() { webSearchEnabled, }) + useEffect(() => { + const messageId = knowledgeJumpTargetRef.current + if (activeSidebarMode !== 'chats' || !messageId) return + if (!messages.some(message => message.message_id === messageId)) return + knowledgeJumpTargetRef.current = null + requestAnimationFrame(() => scrollMessageToTop(messageId, 'smooth', activeSessionId)) + }, [activeSessionId, activeSidebarMode, messages, scrollMessageToTop]) + useEffect(() => { if (!isChatModelPickerOpen) return @@ -1524,6 +1584,9 @@ async function createNewChat() { + @@ -1590,6 +1653,9 @@ async function createNewChat() { + )} @@ -1808,6 +1874,7 @@ async function createNewChat() { apiBase={backendApiUrl} library={activeLibrary} jobs={libraryJobs} + onOpenChatMessage={openChatMessageSource} onRefresh={async () => { await refreshLibraries(); await refreshLibraryJobs(); @@ -1833,6 +1900,18 @@ async function createNewChat() { streamOutput={streamOutput} /> )} + {knowledgeSaveTarget && ( + setKnowledgeSaveTarget(null)} + onSaved={handleKnowledgeSaved} + precedingUserMessage={knowledgeSaveTarget.precedingUserMessage} + /> + )} + {knowledgeSaveToast &&
{knowledgeSaveToast}
} ) diff --git a/src/LibraryManager.jsx b/src/LibraryManager.jsx index 2b4e7d3..04994d8 100644 --- a/src/LibraryManager.jsx +++ b/src/LibraryManager.jsx @@ -54,6 +54,7 @@ function itemSyncMeta(item) { function kindLabel(item) { if (item?.kind === 'text') return 'Text' if (item?.kind === 'website') return 'Website' + if (item?.kind === 'chat_message') return 'Chat Message' return 'File' } @@ -69,7 +70,7 @@ async function expectJson(response) { return data } -export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { +export default function LibraryManager({ apiBase, library, jobs, onOpenChatMessage, onRefresh }) { const [busy, setBusy] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [toasts, setToasts] = useState([]) @@ -332,6 +333,8 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { item.path, item.url, item.kind, + item.source_session_title, + item.source_role, item.metadata?.headline, item.metadata?.summary, ...(item.metadata?.keywords || []), @@ -437,7 +440,13 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { {filteredItems.map(item => { const sync = itemSyncMeta(item) const label = kindLabel(item) - const source = item.kind === 'website' ? item.url : item.kind === 'text' ? 'Written in Heimgeist' : item.path + const source = item.kind === 'website' + ? item.url + : item.kind === 'text' + ? 'Written in Heimgeist' + : item.kind === 'chat_message' + ? `${item.source_role === 'user' ? 'User message' : 'Assistant message'} · ${item.source_session_title || 'Chat'}` + : item.path const itemId = item.item_id || item.sha256 || item.rel const isExpanded = expandedItemId === itemId const preview = contentPreviews[itemId] @@ -509,7 +518,7 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { {isExpanded && (
- {item.kind === 'website' ? 'Saved website text' : item.kind === 'text' ? 'Stored text' : 'Extracted text used by RAG'} + {item.kind === 'website' ? 'Saved website text' : item.kind === 'chat_message' ? 'Stored chat knowledge' : item.kind === 'text' ? 'Stored text' : 'Extracted text used by RAG'}
{preview?.loading &&
Loading content…
} {preview?.error &&
{preview.error}
} @@ -525,7 +534,8 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) { - {item.kind === 'text' && } + {(item.kind === 'text' || item.kind === 'chat_message') && } + {item.kind === 'chat_message' && } {item.kind === 'website' && } {item.kind === 'website' && } {(!item.kind || item.kind === 'file') && } diff --git a/src/SaveMessageToKnowledgeDialog.jsx b/src/SaveMessageToKnowledgeDialog.jsx new file mode 100644 index 0000000..525342e --- /dev/null +++ b/src/SaveMessageToKnowledgeDialog.jsx @@ -0,0 +1,144 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { splitThinkBlocks } from './chatText' + +function cleanAssistantContent(content) { + const raw = String(content || '') + try { + const { answer } = splitThinkBlocks(raw) + return String(answer || raw).trim() + } catch { + return raw.trim() + } +} + +function defaultTitle(message) { + const prefix = message?.role === 'user' ? 'User note' : 'Saved answer' + const content = message?.role === 'assistant' + ? cleanAssistantContent(message?.content) + : String(message?.content || '').trim() + const compact = content.replace(/\s+/g, ' ') + if (!compact) return prefix + const excerpt = compact.length > 90 ? `${compact.slice(0, 87).trim()}...` : compact + return `${prefix}: ${excerpt}` +} + +function defaultKnowledgeContent(message, precedingUserMessage) { + const content = message?.role === 'assistant' + ? cleanAssistantContent(message?.content) + : String(message?.content || '').trim() + if (message?.role !== 'assistant' || !precedingUserMessage?.content?.trim()) { + return content + } + return `User question:\n${precedingUserMessage.content.trim()}\n\nAssistant response:\n${content}` +} + +async function expectJson(response) { + const raw = await response.text() + let data = null + try { + data = raw ? JSON.parse(raw) : null + } catch {} + if (!response.ok) { + throw new Error(data?.detail || raw || `HTTP ${response.status}`) + } + return data +} + +export default function SaveMessageToKnowledgeDialog({ + apiBase, + defaultLibrarySlug, + libraries, + message, + onClose, + onSaved, + precedingUserMessage, +}) { + const initialLibrarySlug = useMemo(() => { + if (libraries.some(library => library.slug === defaultLibrarySlug)) return defaultLibrarySlug + return libraries[0]?.slug || '' + }, [defaultLibrarySlug, libraries]) + const [librarySlug, setLibrarySlug] = useState(initialLibrarySlug) + const [title, setTitle] = useState(() => defaultTitle(message)) + const [content, setContent] = useState(() => defaultKnowledgeContent(message, precedingUserMessage)) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + const onKeyDown = event => { + if (event.key === 'Escape' && !busy) onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [busy, onClose]) + + async function submit(event) { + event.preventDefault() + if (!librarySlug || !message?.message_id) return + setBusy(true) + setError('') + try { + const response = await fetch(`${apiBase}/libraries/${librarySlug}/chat-messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message_id: message.message_id, + title, + content, + }), + }) + const data = await expectJson(response) + await onSaved(data) + } catch (submitError) { + setError(String(submitError?.message || submitError)) + } finally { + setBusy(false) + } + } + + return ( +
{ + if (event.target === event.currentTarget && !busy) onClose() + }}> +
+
+
+ Save message to knowledge +

The saved snapshot becomes a normal RAG item in the selected database.

+
+ +
+ + {libraries.length === 0 ? ( +
Create a database before saving chat knowledge.
+ ) : ( + <> + + +