From 4db6fde9beab7c4065f7f7e55c30f13e52839130 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 15 Jun 2026 03:53:10 +0200 Subject: [PATCH] Feature: Implement knowledge base saving for chat messages Adds functionality to save selected chat messages into an external knowledge database, including new dialog components, state management in App.jsx, updated history syncing, and necessary UI/UX improvements. --- src/App.jsx | 107 +++++++++++++++++--- src/LibraryManager.jsx | 18 +++- src/SaveMessageToKnowledgeDialog.jsx | 144 +++++++++++++++++++++++++++ src/chatGeneration.js | 3 + src/styles.css | 89 +++++++++++++++++ 5 files changed, 343 insertions(+), 18 deletions(-) create mode 100644 src/SaveMessageToKnowledgeDialog.jsx 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.
+ ) : ( + <> + + +