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:
107
src/App.jsx
107
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() {
|
||||
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
<button className="icon-button knowledge-save-button" title="Save message to knowledge" aria-label="Save message to knowledge" onClick={() => openKnowledgeSaveDialog(m, i)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M8.5 14.5A7 7 0 1 1 15.5 14.5C14.6 15.2 14 16.1 14 17h-4c0-.9-.6-1.8-1.5-2.5Z"/></svg>
|
||||
</button>
|
||||
<button className="icon-button" title="Regenerate response" onClick={() => regenerateFromIndex(i)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"></path></svg>
|
||||
</button>
|
||||
@@ -1590,6 +1653,9 @@ async function createNewChat() {
|
||||
<button className="icon-button" title="Copy message" onClick={() => handleCopyMessage(m)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
<button className="icon-button knowledge-save-button" title="Save message to knowledge" aria-label="Save message to knowledge" onClick={() => openKnowledgeSaveDialog(m, i)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M8.5 14.5A7 7 0 1 1 15.5 14.5C14.6 15.2 14 16.1 14 17h-4c0-.9-.6-1.8-1.5-2.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 && (
|
||||
<SaveMessageToKnowledgeDialog
|
||||
apiBase={backendApiUrl}
|
||||
defaultLibrarySlug={chatLibrarySlug || activeLibrarySlug}
|
||||
libraries={libraries}
|
||||
message={knowledgeSaveTarget.message}
|
||||
onClose={() => setKnowledgeSaveTarget(null)}
|
||||
onSaved={handleKnowledgeSaved}
|
||||
precedingUserMessage={knowledgeSaveTarget.precedingUserMessage}
|
||||
/>
|
||||
)}
|
||||
{knowledgeSaveToast && <div className="knowledge-save-toast" role="status">{knowledgeSaveToast}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
<div className="library-content-preview">
|
||||
<div className="library-content-preview-label">
|
||||
{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'}
|
||||
</div>
|
||||
{preview?.loading && <div className="muted-copy">Loading content…</div>}
|
||||
{preview?.error && <div className="form-error">{preview.error}</div>}
|
||||
@@ -525,7 +534,8 @@ export default function LibraryManager({ apiBase, library, jobs, onRefresh }) {
|
||||
<button className="button ghost" disabled={busy} onClick={() => toggleContentPreview(item)}>
|
||||
{isExpanded ? 'Hide Content' : 'View Content'}
|
||||
</button>
|
||||
{item.kind === 'text' && <button className="button ghost" disabled={busy} onClick={() => editText(item)}>Edit</button>}
|
||||
{(item.kind === 'text' || item.kind === 'chat_message') && <button className="button ghost" disabled={busy} onClick={() => editText(item)}>Edit</button>}
|
||||
{item.kind === 'chat_message' && <button className="button ghost" onClick={() => onOpenChatMessage?.(item)}>Open Chat</button>}
|
||||
{item.kind === 'website' && <button className="button ghost" onClick={() => desktopApi.openExternalLink(item.url)}>Open</button>}
|
||||
{item.kind === 'website' && <button className="button ghost" disabled={busy} onClick={() => refreshWebsite(item)}>Refresh</button>}
|
||||
{(!item.kind || item.kind === 'file') && <button className="button ghost" onClick={() => desktopApi.openPath(item.path)}>Open</button>}
|
||||
|
||||
144
src/SaveMessageToKnowledgeDialog.jsx
Normal file
144
src/SaveMessageToKnowledgeDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user