diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py
index e53a074e2e..771883e377 100644
--- a/backend/open_webui/models/chats.py
+++ b/backend/open_webui/models/chats.py
@@ -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_(
diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py
index 465d1bf1a1..5e2ffe1ec5 100644
--- a/backend/open_webui/routers/notes.py
+++ b/backend/open_webui/routers/notes.py
@@ -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
diff --git a/src/app.css b/src/app.css
index 527e92046d..702891eba8 100644
--- a/src/app.css
+++ b/src/app.css
@@ -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 {
diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts
index b5603beeba..ee33948521 100644
--- a/src/lib/apis/notes/index.ts
+++ b/src/lib/apis/notes/index.ts
@@ -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;
diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte
index 4d9e401f8c..3393f9fab7 100644
--- a/src/lib/components/channel/Messages/Message.svelte
+++ b/src/lib/components/channel/Messages/Message.svelte
@@ -525,16 +525,18 @@
{:else}
-
+
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
{:else}
-
{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}
+
+ {#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}
({$i18n.t('edited')}){/if}
{/if}
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte
index bd6600357f..6058792cbc 100644
--- a/src/lib/components/chat/Chat.svelte
+++ b/src/lib/components/chat/Chat.svelte
@@ -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
) | null = null;
+ export let onCreateEmbeddedChat: (() => any | Promise) | null = null;
+ export let onSelectEmbeddedChat: ((chatId: string) => void | Promise) | null = null;
+ export let onDeleteEmbeddedChat: ((chatId: string) => void | Promise) | null = null;
+ export let onEmbeddedChatTitle: ((chatId: string, title: string) => void | Promise) | 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 = {}) => {
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 @@
-
- {embeddedHeaderTitle}
+
+
{
+ if (!state) embeddedChatOptionsId = '';
+ }}
+ >
+
+
+
+
+ {#if onNewEmbeddedChat && Object.keys(history?.messages ?? {}).length > 0 && !loading}
+
+
+ {/if}
+ {#if embeddedChats.length > 0}
+ {#each embeddedChats as item}
+ {
+ 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}
+
+ {$i18n.t('No chat history')}
+
+ {/if}
+
+
+