- {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()
+ }}>
+
+
+ )
+}
diff --git a/src/chatGeneration.js b/src/chatGeneration.js
index d718ae5..b6300ef 100644
--- a/src/chatGeneration.js
+++ b/src/chatGeneration.js
@@ -62,6 +62,7 @@ export function createChatGenerationHandlers({
input,
isSending,
model,
+ onMessagesPersisted,
restoredForRef,
scrollMessageToTop,
scrollToBottom,
@@ -270,6 +271,7 @@ export function createChatGenerationHandlers({
setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId }))
}
}
+ if (onMessagesPersisted) void onMessagesPersisted(sessionId)
} catch (error) {
if (!isAbortError(error)) {
console.error(error)
@@ -542,6 +544,7 @@ export function createChatGenerationHandlers({
if (activeSessionIdRef.current !== targetSessionId) {
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])])
}
+ if (onMessagesPersisted) void onMessagesPersisted(targetSessionId)
if (isNewChat) {
requestGeneratedTitle({
diff --git a/src/styles.css b/src/styles.css
index f3eaa3d..67c3ede 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1796,6 +1796,95 @@ input:checked + .slider:before {
border-color: color-mix(in srgb, #69c4a1 34%, var(--border));
}
+.library-content-card.kind-chat_message {
+ border-color: color-mix(in srgb, #e9c46a 48%, var(--border));
+}
+
+.knowledge-save-button:hover svg {
+ stroke: #e9c46a;
+}
+
+.knowledge-save-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(0, 0, 0, 0.62);
+ backdrop-filter: blur(3px);
+}
+
+.knowledge-save-dialog {
+ display: grid;
+ gap: 14px;
+ width: min(680px, 100%);
+ max-height: calc(100vh - 48px);
+ overflow: auto;
+ padding: 20px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--panel);
+ box-shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
+}
+
+.knowledge-save-dialog label {
+ display: grid;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.knowledge-save-dialog input,
+.knowledge-save-dialog select,
+.knowledge-save-dialog textarea {
+ width: 100%;
+ box-sizing: border-box;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: var(--input-bg);
+ color: var(--text);
+ font: inherit;
+}
+
+.knowledge-save-dialog textarea {
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.knowledge-save-header,
+.knowledge-save-actions {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.knowledge-save-header p {
+ margin: 5px 0 0;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.knowledge-save-actions {
+ justify-content: flex-end;
+}
+
+.knowledge-save-toast {
+ position: fixed;
+ right: 24px;
+ bottom: 24px;
+ z-index: 1300;
+ max-width: min(420px, calc(100vw - 48px));
+ padding: 11px 14px;
+ border: 1px solid color-mix(in srgb, var(--accent) 48%, var(--border));
+ border-radius: 11px;
+ background: var(--panel);
+ color: var(--text);
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
+}
+
.library-content-card.expanded {
grid-column: 1 / -1;
}