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.
This commit is contained in:
2026-06-15 03:53:10 +02:00
parent d0e5c9300c
commit 4db6fde9be
5 changed files with 343 additions and 18 deletions

View File

@@ -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 (
<div className="knowledge-save-overlay" role="presentation" onMouseDown={event => {
if (event.target === event.currentTarget && !busy) onClose()
}}>
<form className="knowledge-save-dialog" role="dialog" aria-modal="true" aria-labelledby="knowledge-save-title" onSubmit={submit}>
<div className="knowledge-save-header">
<div>
<strong id="knowledge-save-title">Save message to knowledge</strong>
<p>The saved snapshot becomes a normal RAG item in the selected database.</p>
</div>
<button type="button" className="icon-button" title="Close" aria-label="Close" onClick={onClose} disabled={busy}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
{libraries.length === 0 ? (
<div className="form-error">Create a database before saving chat knowledge.</div>
) : (
<>
<label>
Database
<select value={librarySlug} onChange={event => setLibrarySlug(event.target.value)} disabled={busy}>
{libraries.map(library => <option key={library.slug} value={library.slug}>{library.name}</option>)}
</select>
</label>
<label>
Title
<input value={title} onChange={event => setTitle(event.target.value)} required autoFocus disabled={busy} />
</label>
<label>
Knowledge content
<textarea value={content} onChange={event => setContent(event.target.value)} rows={12} required disabled={busy} />
</label>
</>
)}
{error && <div className="form-error">{error}</div>}
<div className="knowledge-save-actions">
<button type="button" className="button ghost" onClick={onClose} disabled={busy}>Cancel</button>
<button type="submit" className="button" disabled={busy || libraries.length === 0 || !librarySlug || !title.trim() || !content.trim()}>
{busy ? 'Saving...' : 'Save to Database'}
</button>
</div>
</form>
</div>
)
}