This commit is contained in:
Timothy Jaeryang Baek 2026-07-15 22:34:52 -04:00
parent b16a4c4e9a
commit 185bca8552
20 changed files with 693 additions and 156 deletions

View file

@ -424,11 +424,27 @@ class ChatTable:
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.created_at.asc())
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
chat = result.scalars().first()
return ChatModel.model_validate(chat) if chat else None
async def get_internal_chats_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat)
.where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['type'].as_string() == 'note',
Chat.meta['note_id'].as_string() == note_id,
)
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
)
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
id = str(uuid.uuid4())
chat = ChatModel(
@ -1545,14 +1561,12 @@ class ChatTable:
# Check if there are any tags to filter
if 'none' in tag_ids:
stmt = stmt.filter(
text("""
stmt = stmt.filter(text("""
NOT EXISTS (
SELECT 1
FROM json_each(Chat.meta, '$.tags') AS tag
)
""")
)
"""))
elif tag_ids:
stmt = stmt.filter(
and_(
@ -1595,14 +1609,12 @@ class ChatTable:
).params(title_key=f'%{search_text}%', content_key=search_text.lower())
if 'none' in tag_ids:
stmt = stmt.filter(
text("""
stmt = stmt.filter(text("""
NOT EXISTS (
SELECT 1
FROM json_array_elements_text(Chat.meta->'tags') AS tag
)
""")
)
"""))
elif tag_ids:
stmt = stmt.filter(
and_(

View file

@ -47,14 +47,6 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optiona
return {'content': {'md': md[:max_length]}}
def _note_chat_system_prompt(note_id: str) -> str:
return (
f'You are chatting with note {note_id}. Use view_note with this note id to read the current note. '
'For edits, use replace_note_content for whole-note changes or replace_note_text '
'for targeted exact text replacement.'
)
async def _normalize_note_chat_payload(chat: ChatResponse, note_id: str, db: AsyncSession) -> ChatResponse:
payload = {**(chat.chat or {})}
params = {**(payload.get('params') or {})}
@ -63,7 +55,11 @@ async def _normalize_note_chat_payload(chat: ChatResponse, note_id: str, db: Asy
if params.pop('note_id', None) is not None:
changed = True
system = _note_chat_system_prompt(note_id)
system = (
f'You are chatting with note {note_id}. Use view_note with this note id to read the current note. '
'For edits, use replace_note_content for whole-note changes or replace_note_text '
'for targeted exact text replacement.'
)
if params.get('system') != system:
params['system'] = system
changed = True
@ -375,7 +371,6 @@ async def get_note_chat_by_id(
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return await _normalize_note_chat_payload(chat, note.id, db)
meta = {'internal': True, 'type': 'note', 'note_id': note.id}
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
@ -385,18 +380,123 @@ async def get_note_chat_by_id(
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {'system': _note_chat_system_prompt(note.id)},
'params': {
'system': (
f'You are chatting with note {note.id}. Use view_note with this note id to read the current note. '
'For edits, use replace_note_content for whole-note changes or replace_note_text '
'for targeted exact text replacement.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta=meta,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat
@router.get('/{id}/chats', response_model=list[ChatResponse])
async def get_note_chats_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
return [await _normalize_note_chat_payload(chat, note.id, db) for chat in chats]
@router.post('/{id}/chat', response_model=ChatResponse)
async def create_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if user.role != 'admin' and not await has_permission(
user.id, 'features.notes', await Config.get('user.permissions'), db=db
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = await Notes.get_note_by_id(id, db=db)
if not note:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
if user.role != 'admin' and (
user.id != note.user_id
and not await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
db=db,
)
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
chat_id = str(uuid4())
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {
'system': (
f'You are chatting with note {note.id}. Use view_note with this note id to read the current note. '
'For edits, use replace_note_content for whole-note changes or replace_note_text '
'for targeted exact text replacement.'
)
},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
)
if not chat:
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
return chat

View file

@ -122,7 +122,7 @@ textarea::-webkit-scrollbar-corner {
}
.markdown-prose {
@apply prose dark:prose-invert max-w-none break-words font-normal leading-relaxed prose-p:my-0 prose-p:font-normal prose-p:leading-relaxed prose-headings:my-1 prose-headings:font-normal prose-headings:leading-snug prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 prose-li:font-normal prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-blockquote:font-normal prose-hr:my-4 prose-img:my-1 [&>:first-child]:mt-0 [&>:last-child]:mb-0;
@apply prose prose-sm dark:prose-invert max-w-none break-words font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
}
.markdown-prose-sm {

View file

@ -254,6 +254,62 @@ export const getNoteChatById = async (token: string, id: string) => {
return res;
};
export const getNoteChatsById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chats`;
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const createNoteChatById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
const res = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null;

View file

@ -525,16 +525,18 @@
</div>
</div>
{:else}
<div class=" min-w-full markdown-prose {pending ? 'opacity-50' : ''}">
<div class="min-w-full {pending ? 'opacity-50' : ''}">
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown
id={renderedMessageId}
content={message.content}
paragraphTag="span"
allowEmbeds={!!message?.meta?.model_id}
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
<span class="markdown-prose">
<Markdown
id={renderedMessageId}
content={message.content}
paragraphTag="span"
allowEmbeds={!!message?.meta?.model_id}
/>
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
>{/if}
{/if}

View file

@ -107,6 +107,8 @@
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
import WebSearchConfirmDialog from '../common/ConfirmDialog.svelte';
import Dropdown from '../common/Dropdown.svelte';
import DropdownMenu from '../common/DropdownMenu.svelte';
import Placeholder from './Placeholder.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import NotificationToast from '../NotificationToast.svelte';
@ -116,14 +118,25 @@
import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte';
import EditPencilIcon from '../layout/Sidebar/icons/EditPencil.svelte';
import ChevronRight from '../icons/ChevronRight.svelte';
import EmbeddedChatHistoryItem from './EmbeddedChatHistoryItem.svelte';
export let chatIdProp = '';
export let embedded = false;
export let embeddedTitle = '';
export let embeddedChats = [];
export let embeddedDraftKey = '';
export let initialFiles = [];
export let selectedText = '';
export let onInsertToNote: ((content: string) => void) | null = null;
export let onCloseEmbedded: (() => void) | null = null;
export let onNewEmbeddedChat: (() => void | Promise<void>) | null = null;
export let onCreateEmbeddedChat: (() => any | Promise<any>) | null = null;
export let onSelectEmbeddedChat: ((chatId: string) => void | Promise<void>) | null = null;
export let onDeleteEmbeddedChat: ((chatId: string) => void | Promise<void>) | null = null;
export let onEmbeddedChatTitle: ((chatId: string, title: string) => void | Promise<void>) | null =
null;
let loading = true;
$: chatContainerId = embedded ? 'note-chat-container' : 'chat-container';
@ -133,6 +146,9 @@
'Extract action items from this note.',
'Rewrite the selected text.'
];
let showEmbeddedChatHistory = false;
let embeddedChatOptionsId = '';
let deletingEmbeddedChatId = '';
const eventTarget = new EventTarget();
let controlPane: Pane | undefined;
@ -278,10 +294,7 @@
};
$: contextUsage = getContextUsage() ?? serverContextUsage;
$: embeddedHeaderTitle =
embeddedTitle ||
($chatTitle && !$chatTitle.startsWith('Chat:') ? $chatTitle : '') ||
$i18n.t('Chat');
$: embeddedHeaderTitle = embeddedTitle || $chatTitle || $i18n.t('Chat');
let selectedToolIds = [];
let selectedSkillIds = [];
@ -364,12 +377,12 @@
let params = {};
let appliedInitialFilesKey = '';
let loadedChatIdProp = '';
let loadedEmbeddedDraftKey = '';
const fileKey = (file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
const mergeFiles = (current, incoming) => {
const seen = new Set();
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
const key = fileKey(file);
const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@ -378,7 +391,9 @@
const applyInitialFiles = () => {
if (!embedded || !initialFiles?.length) return;
const key = JSON.stringify(initialFiles.map(fileKey));
const key = JSON.stringify(
initialFiles.map((file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`)
);
if (key === appliedInitialFilesKey) return;
files = mergeFiles(files, initialFiles);
@ -389,10 +404,6 @@
embedded && selectedText?.trim()
? `${text}\n\nSelected note text:\n${selectedText.trim()}`
: text;
const submitEmbeddedPrompt = async (text: string) => {
await tick();
await submitHandler(withSelectedText(text));
};
const noteChatDebug = (message: string, data: Record<string, unknown> = {}) => {
if (!embedded) return;
console.info('[note-chat]', message, {
@ -415,6 +426,12 @@
navigateHandler();
}
$: if (embedded && embeddedDraftKey && embeddedDraftKey !== loadedEmbeddedDraftKey) {
noteChatDebug('embedded draft requested', { embeddedDraftKey });
loadedEmbeddedDraftKey = embeddedDraftKey;
initEmbeddedDraft();
}
let saveControlsTimer;
$: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) {
clearTimeout(saveControlsTimer);
@ -502,6 +519,46 @@
}
};
const initEmbeddedDraft = async () => {
clearTimeout(saveControlsTimer);
await saveControls();
if ($chatId && !$temporaryChatEnabled) {
updateLastReadAt($chatId);
}
loading = true;
loadedChatIdProp = '';
chat = null;
tags = [];
taskIds = null;
chatTasks = [];
serverContextUsage = null;
history = {
messages: {},
currentId: null
};
params = {};
chatFiles = [];
files = [];
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
prompt = '';
messageInput?.setText('');
await chatId.set('');
await chatTitle.set('');
await setDefaults();
loading = false;
await tick();
applyInitialFiles();
document.getElementById('chat-input')?.focus();
};
const onSelect = async (e) => {
const { type, data } = e;
@ -888,6 +945,9 @@
message.favorite = data.favorite;
} else if (type === 'chat:title') {
chatTitle.set(data);
if (embedded && $chatId) {
await onEmbeddedChatTitle?.($chatId, data);
}
await refreshChatList(localStorage.token);
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
@ -2655,9 +2715,26 @@
}
history = history;
// New chat — backend generates the chat_id on first request
// Empty embedded drafts create their backing chat only when the first message is sent.
if (!_chatId) {
if ($temporaryChatEnabled) {
if (embedded && onCreateEmbeddedChat) {
const createdChat = await onCreateEmbeddedChat();
if (!createdChat?.id) {
toast.error($i18n.t('Failed to create chat'));
return;
}
chat = createdChat;
_chatId = createdChat.id;
loadedChatIdProp = _chatId;
await chatId.set(_chatId);
await chatTitle.set(createdChat?.chat?.title ?? createdChat?.title ?? $i18n.t('Chat'));
params = structuredClone(createdChat?.chat?.params ?? {});
delete params.note_id;
chatFiles = mergeFiles(chatFiles, createdChat?.chat?.files ?? []);
applyInitialFiles();
} else if ($temporaryChatEnabled) {
_chatId = `local:${$socket?.id}`;
await chatId.set(_chatId);
}
@ -2954,7 +3031,11 @@
...(continueResponse ? { assistant_message_id: responseMessageId } : {}),
background_tasks: {
...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null
...(!$temporaryChatEnabled &&
(!_chatId ||
(embedded &&
(userMessage?.parentId ?? null) === null &&
createMessagesList(_history, responseMessageId).length === 2))
? {
title_generation: $settings?.title?.auto ?? true,
tags_generation: $settings?.autoTags ?? true
@ -3540,13 +3621,88 @@
<div
class="h-10 shrink-0 flex items-center justify-between gap-2 border-b border-gray-50/80 px-3 text-gray-700 dark:border-gray-850/40 dark:text-gray-200"
>
<div class="min-w-0 truncate text-[13px] font-medium">
{embeddedHeaderTitle}
<div class="flex min-w-0 items-center gap-2">
<Dropdown
bind:show={showEmbeddedChatHistory}
align="start"
sideOffset={6}
closeOnOutsideClick={embeddedChatOptionsId === ''}
onOpenChange={(state) => {
if (!state) embeddedChatOptionsId = '';
}}
>
<button
type="button"
class="group flex min-w-0 items-center gap-1 text-[13px] font-normal text-gray-600 transition hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
aria-label={$i18n.t('Chat history')}
>
<span class="min-w-0 truncate">{embeddedHeaderTitle}</span>
<ChevronRight
className="size-3.5 shrink-0 text-gray-400/70 opacity-0 transition-opacity group-hover:opacity-100 dark:text-gray-500/70"
strokeWidth="2"
/>
</button>
<div slot="content">
<DropdownMenu
className="min-w-56 max-w-72 max-h-80 overflow-y-auto scrollbar-hidden"
>
{#if onNewEmbeddedChat && Object.keys(history?.messages ?? {}).length > 0 && !loading}
<button
type="button"
class="text-left"
on:click={async () => {
showEmbeddedChatHistory = false;
await onNewEmbeddedChat?.();
}}
>
<EditPencilIcon className="size-3.5" strokeWidth="1.5" />
<span class="min-w-0 truncate">{$i18n.t('New chat')}</span>
</button>
<hr class="border-gray-100/70 dark:border-gray-800/60" />
{/if}
{#if embeddedChats.length > 0}
{#each embeddedChats as item}
<EmbeddedChatHistoryItem
{item}
title={item?.id === $chatId
? embeddedHeaderTitle
: item?.title || item?.chat?.title || $i18n.t('Chat')}
selected={item.id === $chatId}
deleting={deletingEmbeddedChatId === item.id}
onSelect={async () => {
showEmbeddedChatHistory = false;
await onSelectEmbeddedChat?.(item.id);
}}
onDelete={async (id) => {
if (!id || deletingEmbeddedChatId) return;
deletingEmbeddedChatId = id;
embeddedChatOptionsId = '';
try {
await onDeleteEmbeddedChat?.(id);
} finally {
deletingEmbeddedChatId = '';
}
}}
onMenuOpenChange={(id, state) => {
embeddedChatOptionsId = state ? id : '';
}}
/>
{/each}
{:else}
<div class="px-2 py-1.5 text-[13px] text-gray-400 dark:text-gray-500">
{$i18n.t('No chat history')}
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>
</div>
<Tooltip content={$i18n.t('Close')} placement="bottom">
<button
type="button"
class="rounded-md p-1 text-gray-500 transition hover:bg-black/5 hover:text-gray-900 dark:hover:bg-white/5 dark:hover:text-white"
class="rounded-md p-1 text-gray-500 transition hover:text-gray-900 dark:hover:text-white"
on:click={() => onCloseEmbedded?.()}
aria-label={$i18n.t('Close')}
>
@ -3610,13 +3766,12 @@
toast.success($i18n.t('Conversation saved successfully'));
}
} catch (error) {
console.error('Error saving conversation:', error);
console.error('Failed to save temporary chat:', error);
toast.error($i18n.t('Failed to save conversation'));
}
}}
/>
{/if}
<div id="chat-pane" class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
<div
@ -3768,7 +3923,10 @@
<button
type="button"
class="flex min-h-8 w-full items-center justify-between py-1 text-left text-[13px] leading-5 text-gray-500 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
on:click={() => submitEmbeddedPrompt(suggestion)}
on:click={async () => {
await tick();
await submitHandler(withSelectedText(suggestion));
}}
>
<span class="min-w-0 truncate">{$i18n.t(suggestion)}</span>
</button>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
const i18n = getContext('i18n');
export let item: any;
export let title = '';
export let selected = false;
export let deleting = false;
export let onSelect: (id: string) => void | Promise<void> = () => {};
export let onDelete: (id: string) => void | Promise<void> = () => {};
export let onMenuOpenChange: (id: string, state: boolean) => void = () => {};
let showMenu = false;
</script>
<button
type="button"
class="group/item flex h-8 w-full cursor-pointer select-none items-center rounded-xl px-2 text-left text-[13px] font-normal text-gray-700 outline-hidden transition-colors duration-75 hover:bg-gray-50/40 dark:text-gray-100 dark:hover:bg-gray-800/40 {selected
? 'bg-gray-50/70 dark:bg-gray-800/60'
: ''}"
on:click={() => onSelect(item.id)}
>
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div class="min-w-0 truncate">{title}</div>
{#if selected}
<div class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{$i18n.t('Current')}
</div>
{/if}
</div>
<div class="ml-auto flex shrink-0 items-center gap-1.5 pl-2">
<Dropdown
bind:show={showMenu}
align="end"
sideOffset={-2}
onOpenChange={(state) => {
showMenu = state;
onMenuOpenChange(item.id, state);
}}
>
<Tooltip content={$i18n.t('More')} className="group-hover/item:opacity-100 opacity-0">
<button
type="button"
aria-label={$i18n.t('More Options')}
class="flex"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
showMenu = !showMenu;
onMenuOpenChange(item.id, showMenu);
}}
>
<EllipsisHorizontal className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[160px] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
disabled={deleting}
on:click={async (e) => {
e.preventDefault();
e.stopPropagation();
showMenu = false;
onMenuOpenChange(item.id, false);
await onDelete(item.id);
}}
>
<GarbageBin className="size-3.5" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
</button>

View file

@ -283,31 +283,35 @@
onPreview={previewHandler}
/>
{:else if $settings?.renderMarkdownInAssistantMessages ?? true}
<Markdown
{id}
content={formatMessageContent(content)}
{model}
{save}
{preview}
{done}
{editCodeBlock}
{topPadding}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onSave}
onUpdate={markdownUpdateHandler}
onPreview={previewHandler}
/>
<div class="markdown-prose">
<Markdown
{id}
content={formatMessageContent(content)}
{model}
{save}
{preview}
{done}
{editCodeBlock}
{topPadding}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onSave}
onUpdate={markdownUpdateHandler}
onPreview={previewHandler}
/>
</div>
{:else}
{@const extracted = extractDetailsBlocks(content)}
{#if extracted.detailsContent}
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
<Markdown {id} content={extracted.detailsContent} {done} />
<div class="markdown-prose">
<Markdown {id} content={extracted.detailsContent} {done} />
</div>
{/if}
{#if extracted.plainContent}
<div class="whitespace-pre-wrap">{extracted.plainContent}</div>
<div class="whitespace-pre-wrap text-sm">{extracted.plainContent}</div>
{/if}
{/if}
</div>

View file

@ -38,11 +38,11 @@
</script>
<div
class="my-2 flex w-full items-start gap-3 rounded-3xl bg-black/[0.03] px-4 py-3 text-gray-500 dark:bg-white/[0.04] dark:text-gray-400"
class="my-1.5 flex w-full items-start gap-2 rounded-2xl bg-black/[0.03] px-3 py-2 text-gray-500 dark:bg-white/[0.04] dark:text-gray-400"
>
<Info className="mt-0.5 size-5 shrink-0 text-gray-400 dark:text-gray-500" strokeWidth="2" />
<Info className="mt-0.5 size-4 shrink-0 text-gray-400 dark:text-gray-500" strokeWidth="1.8" />
<div class="min-w-0 break-words text-sm leading-6">
<div class="min-w-0 break-words text-[0.8125rem] leading-5">
{message}
</div>
</div>

View file

@ -10,8 +10,7 @@
import citationExtension from '$lib/utils/marked/citation-extension';
const options = {
throwOnError: false,
breaks: true
throwOnError: false
};
marked.use(markedKatexExtension(options));

View file

@ -112,7 +112,7 @@
<div {id} class="w-full">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<button
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
class="w-fit py-1 text-left text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
aria-label={$i18n.t('Toggle details')}
aria-expanded={open}
on:click={() => {
@ -158,7 +158,7 @@
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<div class="mb-0.5 space-y-0.5">
<div class="mb-1 space-y-1.5">
<slot name="content" />
</div>
</div>

View file

@ -98,6 +98,9 @@
};
$: displayTokens = getDisplayTokens(tokens);
$: singlePlainBlock =
displayTokens.length === 1 &&
(displayTokens[0]?.type === 'paragraph' || displayTokens[0]?.type === 'text');
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
console.log('Exporting table to CSV');
@ -141,7 +144,7 @@
<!-- {JSON.stringify(tokens)} -->
{#each displayTokens as token, tokenIdx (tokenIdx)}
{#if token.type === 'hr'}
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
<hr class="border-gray-50 dark:border-gray-850/30" />
{:else if token.type === 'heading'}
<svelte:element this={headerComponent(token.depth)} dir="auto">
<MarkdownInlineTokens
@ -385,7 +388,7 @@
resultContent={getDetailTextContent(detailToken)}
grouped={true}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{:else if textContent.length > 0}
<Collapsible
@ -393,7 +396,7 @@
open={$settings?.expandDetails ?? false}
attributes={detailToken?.attributes}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
dir="auto"
>
<div class="mb-1.5" slot="content">
@ -416,7 +419,7 @@
disabled={true}
attributes={detailToken?.attributes}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
dir="auto"
/>
{/if}
@ -433,7 +436,7 @@
attributes={token.attributes}
resultContent={getDetailTextContent(token)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{:else if textContent.length > 0}
<Collapsible
@ -441,7 +444,7 @@
open={$settings?.expandDetails ?? false}
attributes={token?.attributes}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
dir="auto"
>
<div class=" mb-1.5" slot="content">
@ -464,7 +467,7 @@
disabled={true}
attributes={token?.attributes}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
dir="auto"
/>
{/if}
@ -495,7 +498,7 @@
/>
</span>
{:else}
<p dir="auto">
<p dir="auto" class={singlePlainBlock ? '!my-0' : ''}>
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-p`}
tokens={token.tokens ?? []}
@ -507,7 +510,7 @@
{/if}
{:else if token.type === 'text'}
{#if top}
<p>
<p class={singlePlainBlock ? '!my-0' : ''}>
{#if token.tokens}
<MarkdownInlineTokens
id={`${id}-${tokenIdx}-t`}
@ -551,7 +554,7 @@
{onSourceClick}
/>
{:else if token.type === 'space'}
<div class="my-2" />
<!-- skip -->
{:else}
{console.log('Unknown token', token)}
{/if}

View file

@ -405,11 +405,13 @@
{$i18n.t('Merged Response')}
</Name>
<div class="mt-1 markdown-prose w-full min-w-full">
<div class="mt-1 w-full min-w-full">
{#if (message?.content ?? '') === ''}
<Skeleton />
{:else}
<Markdown id={`merged`} content={message.content ?? ''} />
<div class="markdown-prose">
<Markdown id={`merged`} content={message.content ?? ''} />
</div>
{/if}
</div>

View file

@ -1,3 +1,3 @@
<div class=" self-center font-normal line-clamp-1 flex gap-1 items-center">
<div class=" self-center text-sm font-normal line-clamp-1 flex gap-1 items-center">
<slot />
</div>

View file

@ -655,10 +655,10 @@
dir={$settings.chatDirection}
style="scroll-margin-top: 3rem;"
>
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
<div class={`shrink-0 ltr:mr-2 rtl:ml-2 hidden @lg:flex mt-0.5 `}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}`}
className={'size-8 assistant-message-profile-image'}
className={'size-7 assistant-message-profile-image'}
/>
</div>
@ -672,7 +672,7 @@
</Name>
<div>
<div class="chat-{message.role} w-full min-w-full markdown-prose">
<div class="chat-{message.role} w-full min-w-full">
<div>
{#if model?.info?.meta?.capabilities?.status_updates ?? true}
<StatusHistory statusHistory={message?.statusHistory} />

View file

@ -38,24 +38,26 @@
{#each displayItems as displayItem (displayItem.id)}
{#if displayItem.type === 'message'}
{#if renderMarkdown}
<Markdown
id={`${id}-${displayItem.id}`}
content={formatMessageContent(displayItem.text)}
{model}
{save}
{preview}
{done}
{editCodeBlock}
{topPadding}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onSave}
{onUpdate}
{onPreview}
/>
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}`}
content={formatMessageContent(displayItem.text)}
{model}
{save}
{preview}
{done}
{editCodeBlock}
{topPadding}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onSave}
{onUpdate}
{onPreview}
/>
</div>
{:else}
<div class="whitespace-pre-wrap">{displayItem.text}</div>
<div class="whitespace-pre-wrap text-sm">{displayItem.text}</div>
{/if}
{:else if displayItem.type === 'detail_group'}
<ConsecutiveDetailsGroup
@ -63,7 +65,7 @@
tokens={displayItem.tokens}
messageDone={done}
>
<div slot="content" class="space-y-1">
<div slot="content" class="space-y-2">
{#each displayItem.tokens as detailToken, detailIndex}
{#if detailToken.attributes?.type === 'tool_calls'}
<ToolCallDisplay
@ -72,7 +74,7 @@
resultContent={detailToken.text}
grouped={true}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{:else if detailToken.text?.length > 0}
<Collapsible
@ -80,15 +82,17 @@
open={$settings?.expandDetails ?? false}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
>
<div class="mb-1.5" slot="content">
<Markdown
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
content={detailToken.text}
{done}
{editCodeBlock}
/>
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
content={detailToken.text}
{done}
{editCodeBlock}
/>
</div>
</div>
</Collapsible>
{:else}
@ -98,7 +102,7 @@
disabled={true}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{/if}
{/each}
@ -112,7 +116,7 @@
attributes={detailToken.attributes}
resultContent={detailToken.text}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{:else if detailToken.text?.length > 0}
<Collapsible
@ -120,15 +124,17 @@
open={$settings?.expandDetails ?? false}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
>
<div class="mb-1.5" slot="content">
<Markdown
id={`${id}-${displayItem.id}-detail`}
content={detailToken.text}
{done}
{editCodeBlock}
/>
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-detail`}
content={detailToken.text}
{done}
{editCodeBlock}
/>
</div>
</div>
</Collapsible>
{:else}
@ -138,7 +144,7 @@
disabled={true}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-1"
className="w-full space-y-2"
/>
{/if}
{/if}

View file

@ -144,12 +144,12 @@
style="scroll-margin-top: 3rem;"
>
{#if !($settings?.chatBubble ?? true) && !subagentResult}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<div class={`shrink-0 ltr:mr-2 rtl:ml-2 mt-0.5`}>
<ProfileImage
src={user?.id
? `${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`
: `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 user-message-profile-image'}
className={'size-7 user-message-profile-image'}
/>
</div>
{/if}
@ -169,7 +169,7 @@
</div>
{/if}
<div class="chat-{message.role} w-full min-w-full markdown-prose">
<div class="chat-{message.role} w-full min-w-full">
{#if edit !== true}
{#if message.files}
<div
@ -271,7 +271,7 @@
<textarea
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-hidden w-full resize-none"
class=" bg-transparent outline-hidden w-full resize-none text-sm"
bind:value={editedContent}
on:input={(e) => {
const messagesContainer = document.getElementById('messages-container');
@ -349,14 +349,16 @@
>
{#if message.content}
{#if $settings?.renderMarkdownInUserMessages ?? true}
<Markdown
id={`${chatId}-${message.id}`}
content={message.content}
{editCodeBlock}
{topPadding}
/>
<div class="markdown-prose">
<Markdown
id={`${chatId}-${message.id}`}
content={message.content}
{editCodeBlock}
{topPadding}
/>
</div>
{:else}
<div class="whitespace-pre-wrap" dir={$settings?.chatDirection ?? 'auto'}>
<div class="whitespace-pre-wrap text-sm" dir={$settings?.chatDirection ?? 'auto'}>
{message.content}
</div>
{/if}

View file

@ -40,7 +40,7 @@
export let className = '';
export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
'w-fit py-1 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
export let id = '';
export let title = null;

View file

@ -38,7 +38,7 @@
$: if (!open) expandedResult = false;
export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
'w-fit py-1 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
const componentId = id || uuidv4();

View file

@ -63,13 +63,16 @@
import {
deleteNoteById,
createNoteChatById,
getNoteById,
getNoteChatById,
getNoteChatsById,
updateNoteById,
updateNoteAccessGrants,
toggleNotePinnedStatusById,
getPinnedNoteList
} from '$lib/apis/notes';
import { deleteChatById } from '$lib/apis/chats';
import RichTextInput from '../common/RichTextInput.svelte';
import Spinner from '../common/Spinner.svelte';
@ -134,8 +137,23 @@
let showNoteChat = false;
let noteChatId = null;
let noteChatLoading = false;
let noteChats = [];
let noteChatDraftKey = '';
let noteChatCreating = false;
let selectedContent = null;
let noteChatFiles = [];
$: {
const seen = new Set();
noteChatFiles = note
? (note?.data?.files ?? files ?? []).filter((file) => {
const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
: [];
}
let showDeleteConfirm = false;
let showAccessControlModal = false;
@ -540,20 +558,6 @@ ${content}
});
};
const noteChatFileKey = (file) =>
`${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
const getNoteChatFiles = () => {
if (!note) return [];
const seen = new Set();
return (note?.data?.files ?? files ?? []).filter((file) => {
const key = noteChatFileKey(file);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const openNoteChat = async () => {
console.info('[note-chat] open requested', {
noteId: note?.id,
@ -569,6 +573,12 @@ ${content}
toast.error(`${error}`);
return null;
});
const chats = chat
? await getNoteChatsById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] history failed', { noteId: note?.id, error });
return null;
})
: null;
noteChatLoading = false;
if (chat?.id) {
@ -579,12 +589,84 @@ ${content}
hasChatPayload: !!chat.chat
});
noteChatId = chat.id;
noteChats = chats ?? [chat];
showNoteChat = true;
} else {
console.warn('[note-chat] open returned no chat id', { noteId: note.id, chat });
}
};
const createNoteChat = async () => {
if (!note?.id || noteChatLoading) return;
noteChatId = null;
noteChatDraftKey = `${Date.now()}`;
showNoteChat = true;
};
const createNoteChatOnFirstMessage = async () => {
if (!note?.id || noteChatCreating) return null;
noteChatCreating = true;
try {
const chat = await createNoteChatById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] create failed', { noteId: note?.id, error });
toast.error(`${error}`);
return null;
});
const chats = chat
? await getNoteChatsById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] history failed', { noteId: note?.id, error });
return null;
})
: null;
if (chat?.id) {
noteChatId = chat.id;
noteChatDraftKey = '';
noteChats = chats ?? [chat, ...noteChats.filter((item) => item.id !== chat.id)];
showNoteChat = true;
}
return chat;
} finally {
noteChatCreating = false;
}
};
const deleteNoteChat = async (chatId) => {
if (!note?.id || !chatId) return;
const deleted = await deleteChatById(localStorage.token, chatId).catch((error) => {
console.error('[note-chat] delete failed', { noteId: note?.id, chatId, error });
toast.error(`${error}`);
return null;
});
if (!deleted) return;
let chats =
(await getNoteChatsById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] history failed', { noteId: note?.id, error });
return null;
})) ?? [];
if (noteChatId === chatId) {
let nextChat = chats[0];
if (!nextChat) {
nextChat = await getNoteChatById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] recreate failed after delete', { noteId: note?.id, error });
toast.error(`${error}`);
return null;
});
chats = nextChat ? [nextChat] : [];
}
noteChatId = nextChat?.id ?? null;
}
noteChats = chats;
};
const downloadHandler = async (type) => {
console.log('downloadHandler', type);
if (type === 'txt') {
@ -1241,13 +1323,36 @@ ${content}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if noteChatId}
{:else if noteChatId || noteChatDraftKey}
<Chat
embedded={true}
chatIdProp={noteChatId}
initialFiles={getNoteChatFiles()}
chatIdProp={noteChatId ?? ''}
embeddedChats={noteChats}
embeddedDraftKey={noteChatDraftKey}
initialFiles={noteChatFiles}
selectedText={selectedContent?.text ?? ''}
onInsertToNote={insertHandler}
onNewEmbeddedChat={createNoteChat}
onCreateEmbeddedChat={createNoteChatOnFirstMessage}
onSelectEmbeddedChat={(chatId) => {
if (!chatId || chatId === noteChatId) return;
noteChatId = chatId;
}}
onDeleteEmbeddedChat={deleteNoteChat}
onEmbeddedChatTitle={(chatId, title) => {
noteChats = noteChats.map((chat) =>
chat.id === chatId
? {
...chat,
title,
chat: {
...(chat.chat ?? {}),
title
}
}
: chat
);
}}
onCloseEmbedded={() => {
showNoteChat = false;
}}