This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-15 21:43:47 -04:00
1 parent 4d11553a6b
commit 423cafd4e7
21 files changed
+803 -1158

No files matched your search

+17
View File
@@ -412,6 +412,23 @@ class ChatTable:
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def get_internal_chat_by_note_id(
self, note_id: str, user_id: str, db: AsyncSession | None = None
) -> ChatModel | None:
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.created_at.asc())
)
chat = result.scalars().first()
return ChatModel.model_validate(chat) if chat else None
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel: def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
chat = ChatModel( chat = ChatModel(
+98
View File
@@ -1,6 +1,7 @@
import json import json
import logging import logging
from typing import Optional from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from open_webui.config import ( from open_webui.config import (
@@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrants from open_webui.models.access_grants import AccessGrants
from open_webui.models.chats import ChatForm, ChatResponse, Chats
from open_webui.models.config import Config from open_webui.models.config import Config
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.notes import ( from open_webui.models.notes import (
@@ -45,6 +47,39 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optiona
return {'content': {'md': md[:max_length]}} 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 {})}
changed = False
if params.pop('note_id', None) is not None:
changed = True
system = _note_chat_system_prompt(note_id)
if params.get('system') != system:
params['system'] = system
changed = True
if payload.pop('system', None) is not None:
changed = True
payload['params'] = params
if changed:
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
if updated_chat:
return updated_chat
return chat
############################ ############################
# GetNotes # GetNotes
############################ ############################
@@ -303,6 +338,69 @@ async def get_note_by_id(
) )
@router.get('/{id}/chat', response_model=ChatResponse)
async def get_note_chat_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
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 = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
if chat:
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,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': 'Chat',
'models': [''],
'params': {'system': _note_chat_system_prompt(note.id)},
'history': {'messages': {}, 'currentId': None},
'messages': [],
'tags': [],
}
),
db=db,
internal_meta=meta,
)
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
############################ ############################
# UpdateNoteById # UpdateNoteById
############################ ############################
+125 -14
View File
@@ -49,6 +49,8 @@ from open_webui.routers.memories import (
add_memory as _add_memory, add_memory as _add_memory,
) )
from open_webui.routers.retrieval import search_web as _search_web from open_webui.routers.retrieval import search_web as _search_web
from open_webui.events import EVENTS, publish_event
from open_webui.socket.main import sio
from open_webui.utils.sanitize import sanitize_code from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -56,6 +58,33 @@ log = logging.getLogger(__name__)
MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000 MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
async def _has_write_access_to_note(note, user_id: str) -> bool:
if note.user_id == user_id:
return True
from open_webui.models.access_grants import AccessGrants
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
return await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='write',
user_group_ids=set(user_group_ids),
)
async def _emit_note_updated(request: Request, user: dict, note) -> None:
await sio.emit('note-events', note.model_dump(), to=f'note:{note.id}')
await publish_event(
request,
EVENTS.NOTE_UPDATED,
actor=user,
subject_id=note.id,
data={'title': note.title},
)
async def _has_read_access_to_file( async def _has_read_access_to_file(
file, file,
user_id: str, user_id: str,
@@ -1155,23 +1184,21 @@ async def replace_note_content(
if not note: if not note:
return json.dumps({'error': 'Note not found'}) return json.dumps({'error': 'Note not found'})
# Check write permission
user_id = __user__.get('id') user_id = __user__.get('id')
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] if not await _has_write_access_to_note(note, user_id):
from open_webui.models.access_grants import AccessGrants
if note.user_id != user_id and not await AccessGrants.has_access(
user_id=user_id,
resource_type='note',
resource_id=note.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Write access denied'}) return json.dumps({'error': 'Write access denied'})
# Build update form update_data = {
update_data = {'data': {'content': {'md': content}}} 'data': {
**(note.data or {}),
'content': {
**((note.data or {}).get('content') or {}),
'json': None,
'html': '',
'md': content,
},
}
}
if title: if title:
update_data['title'] = title update_data['title'] = title
@@ -1181,6 +1208,8 @@ async def replace_note_content(
if not updated_note: if not updated_note:
return json.dumps({'error': 'Failed to update note'}) return json.dumps({'error': 'Failed to update note'})
await _emit_note_updated(__request__, __user__, updated_note)
return json.dumps( return json.dumps(
{ {
'status': 'success', 'status': 'success',
@@ -1195,6 +1224,88 @@ async def replace_note_content(
return json.dumps({'error': str(e)}) return json.dumps({'error': str(e)})
async def replace_note_text(
note_id: str,
target_text: str,
replacement_text: str,
title: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Replace an exact text span in a note when it occurs exactly once.
:param note_id: The ID of the note to update
:param target_text: Exact text to replace
:param replacement_text: Replacement text
:param title: Optional new title for the note
:return: JSON with success status or a clear match-count error
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.notes import NoteUpdateForm
note = await Notes.get_note_by_id(note_id)
if not note:
return json.dumps({'error': 'Note not found'})
user_id = __user__.get('id')
if not await _has_write_access_to_note(note, user_id):
return json.dumps({'error': 'Write access denied'})
if target_text == '':
return json.dumps({'error': 'target_text must not be empty'})
content = ((note.data or {}).get('content') or {}).get('md') or ''
match_count = content.count(target_text)
if match_count != 1:
return json.dumps(
{
'error': 'target_text must occur exactly once',
'match_count': match_count,
},
ensure_ascii=False,
)
update_data = {
'data': {
**(note.data or {}),
'content': {
**((note.data or {}).get('content') or {}),
'json': None,
'html': '',
'md': content.replace(target_text, replacement_text, 1),
},
}
}
if title:
update_data['title'] = title
updated_note = await Notes.update_note_by_id(note_id, NoteUpdateForm(**update_data))
if not updated_note:
return json.dumps({'error': 'Failed to update note'})
await _emit_note_updated(__request__, __user__, updated_note)
return json.dumps(
{
'status': 'success',
'id': updated_note.id,
'title': updated_note.title,
'updated_at': updated_note.updated_at,
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'replace_note_text error: {e}')
return json.dumps({'error': str(e)})
# ============================================================================= # =============================================================================
# CHATS TOOLS # CHATS TOOLS
# ============================================================================= # =============================================================================
+1
View File
@@ -1881,6 +1881,7 @@ def apply_params_to_form_data(form_data, model):
'reasoning_tags': list, 'reasoning_tags': list,
'compact_token_threshold': int, 'compact_token_threshold': int,
'system': str, 'system': str,
'note_id': str,
} }
for key in list(params.keys()): for key in list(params.keys()):
+2 -1
View File
@@ -72,6 +72,7 @@ from open_webui.tools.builtin import (
read_memory_path, read_memory_path,
replace_memory_content, replace_memory_content,
replace_note_content, replace_note_content,
replace_note_text,
search_calendar_events, search_calendar_events,
search_channel_messages, search_channel_messages,
search_channels, search_channels,
@@ -635,7 +636,7 @@ async def get_builtin_tools(
# Notes tools - search, view, create, and update user's notes # Notes tools - search, view, create, and update user's notes
if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'): if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'):
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) builtin_functions.extend([search_notes, view_note, write_note, replace_note_content, replace_note_text])
# Channels tools - search channels and messages # Channels tools - search channels and messages
if is_builtin_tool_enabled('channels') and config.get('channels.enable') and await has_user_permission('channels'): if is_builtin_tool_enabled('channels') and config.get('channels.enable') and await has_user_permission('channels'):
+1 -1
View File
@@ -122,7 +122,7 @@ textarea::-webkit-scrollbar-corner {
} }
.markdown-prose { .markdown-prose {
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @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;
} }
.markdown-prose-sm { .markdown-prose-sm {
+36
View File
@@ -218,6 +218,42 @@ export const getNoteById = async (token: string, id: string) => {
return res; return res;
}; };
export const getNoteChatById = async (token: string, id: string) => {
let error = null;
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
console.info('[note-chat] fetching linked chat', { noteId: id, url });
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
console.info('[note-chat] linked chat response', {
noteId: id,
status: res.status,
ok: res.ok
});
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error('[note-chat] linked chat request failed', { noteId: id, error: err });
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => { export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null; let error = null;
+349 -97
View File
@@ -115,10 +115,24 @@
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte'; import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte';
export let chatIdProp = ''; export let chatIdProp = '';
export let embedded = false;
export let embeddedTitle = '';
export let initialFiles = [];
export let selectedText = '';
export let onInsertToNote: ((content: string) => void) | null = null;
export let onCloseEmbedded: (() => void) | null = null;
let loading = true; let loading = true;
$: chatContainerId = embedded ? 'note-chat-container' : 'chat-container';
const embeddedSuggestedPrompts = [
'Enhance this note and update it.',
'Summarize this note.',
'Extract action items from this note.',
'Rewrite the selected text.'
];
const eventTarget = new EventTarget(); const eventTarget = new EventTarget();
let controlPane: Pane | undefined; let controlPane: Pane | undefined;
@@ -155,6 +169,30 @@
let serverContextUsage = null; let serverContextUsage = null;
let contextUsage = null; let contextUsage = null;
const getAvailableModelIds = () =>
$models.filter((m) => !(m?.info?.meta?.hidden ?? false)).map((m) => m.id);
const getDefaultModelIds = () =>
$config?.default_models ? $config.default_models.split(',') : [];
const normalizeSelectedModels = (modelIds = []) => {
const availableModels = getAvailableModelIds();
const defaultModels = getDefaultModelIds();
let normalized = (modelIds ?? []).filter(
(modelId) => modelId && availableModels.includes(modelId)
);
if (normalized.length === 0 && $settings?.models?.length) {
normalized = $settings.models.filter((modelId) => availableModels.includes(modelId));
}
if (normalized.length === 0 && defaultModels.length > 0) {
normalized = defaultModels.filter((modelId) => availableModels.includes(modelId));
}
if (normalized.length === 0) {
normalized = availableModels.length > 0 ? [availableModels[0]] : [''];
}
return normalized;
};
const estimateTokens = (value) => { const estimateTokens = (value) => {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return 0; return 0;
@@ -240,6 +278,10 @@
}; };
$: contextUsage = getContextUsage() ?? serverContextUsage; $: contextUsage = getContextUsage() ?? serverContextUsage;
$: embeddedHeaderTitle =
embeddedTitle ||
($chatTitle && !$chatTitle.startsWith('Chat:') ? $chatTitle : '') ||
$i18n.t('Chat');
let selectedToolIds = []; let selectedToolIds = [];
let selectedSkillIds = []; let selectedSkillIds = [];
@@ -320,8 +362,56 @@
let chatFiles = []; let chatFiles = [];
let files = []; let files = [];
let params = {}; let params = {};
let appliedInitialFilesKey = '';
let loadedChatIdProp = '';
$: if (chatIdProp) { 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);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const applyInitialFiles = () => {
if (!embedded || !initialFiles?.length) return;
const key = JSON.stringify(initialFiles.map(fileKey));
if (key === appliedInitialFilesKey) return;
files = mergeFiles(files, initialFiles);
chatFiles = mergeFiles(chatFiles, initialFiles);
appliedInitialFilesKey = key;
};
const withSelectedText = (text: string) =>
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, {
chatIdProp,
activeChatId: $chatId,
loading,
...data
});
};
$: if (embedded && !loading) {
applyInitialFiles();
}
$: if (chatIdProp && chatIdProp !== loadedChatIdProp) {
noteChatDebug('chatIdProp changed; loading linked chat', {
previousChatIdProp: loadedChatIdProp
});
loadedChatIdProp = chatIdProp;
navigateHandler(); navigateHandler();
} }
@@ -332,9 +422,11 @@
} }
const navigateHandler = async () => { const navigateHandler = async () => {
noteChatDebug('navigateHandler start');
// Mark the outgoing chat as read before loading the new one. // Mark the outgoing chat as read before loading the new one.
// $chatId still holds the previous chat here — loadChat() updates it. // $chatId still holds the previous chat here — loadChat() updates it.
if ($chatId && $chatId !== chatIdProp && !$temporaryChatEnabled) { if ($chatId && $chatId !== chatIdProp && !$temporaryChatEnabled) {
noteChatDebug('marking outgoing chat read', { outgoingChatId: $chatId });
updateLastReadAt($chatId); updateLastReadAt($chatId);
} }
@@ -356,9 +448,12 @@
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}` `chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
); );
if (chatIdProp && (await loadChat())) { const loaded = chatIdProp ? await loadChat() : false;
noteChatDebug('loadChat completed inside navigateHandler', { loaded });
if (loaded) {
await tick(); await tick();
loading = false; loading = false;
noteChatDebug('embedded chat loading false');
window.setTimeout(() => scrollToBottom(), 0); window.setTimeout(() => scrollToBottom(), 0);
await tick(); await tick();
@@ -396,8 +491,14 @@
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
chatInput?.focus(); chatInput?.focus();
} else { } else if (!embedded) {
await goto('/'); await goto('/');
} else {
loading = false;
console.warn('[note-chat] embedded load failed; clearing spinner', {
chatIdProp,
activeChatId: $chatId
});
} }
}; };
@@ -1548,7 +1649,7 @@
await showCallOverlay.set(false); await showCallOverlay.set(false);
await showArtifacts.set(false); await showArtifacts.set(false);
if ($page.url.pathname.includes('/c/')) { if (!embedded && $page.url.pathname.includes('/c/')) {
window.history.replaceState(history.state, '', `/`); window.history.replaceState(history.state, '', `/`);
} }
@@ -1676,27 +1777,53 @@
}; };
const loadChat = async () => { const loadChat = async () => {
noteChatDebug('loadChat start');
// chatIdProp is empty for chats started from the home page (URL set via replaceState) // chatIdProp is empty for chats started from the home page (URL set via replaceState)
chatId.set(chatIdProp || $chatId); chatId.set(chatIdProp || $chatId);
noteChatDebug('loadChat set active chat id');
if ($temporaryChatEnabled) { if ($temporaryChatEnabled) {
noteChatDebug('loadChat disabling temporary chat');
temporaryChatEnabled.set(false); temporaryChatEnabled.set(false);
} }
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => { chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
await goto('/'); console.error('[note-chat] getChatById failed', {
chatIdProp,
activeChatId: $chatId,
error
});
if (!embedded) {
await goto('/');
}
return null; return null;
}); });
noteChatDebug('getChatById completed', {
found: !!chat,
chatId: chat?.id,
hasChatPayload: !!chat?.chat,
title: chat?.title
});
if (chat) { if (chat) {
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => { tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
console.warn('[note-chat] getTagsById failed; continuing without tags', {
chatIdProp,
activeChatId: $chatId,
error
});
return []; return [];
}); });
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
const chatContent = chat.chat; const chatContent = chat.chat;
if (chatContent) { if (chatContent) {
console.log(chatContent); noteChatDebug('chat payload found', {
models: chatContent?.models,
hasHistory: !!chatContent?.history,
messageCount: Object.keys(chatContent?.history?.messages ?? {}).length
});
selectedModels = selectedModels =
(chatContent?.models ?? undefined) !== undefined (chatContent?.models ?? undefined) !== undefined
@@ -1706,6 +1833,13 @@
if (!($user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true))) { if (!($user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true))) {
selectedModels = selectedModels.length > 0 ? [selectedModels[0]] : ['']; selectedModels = selectedModels.length > 0 ? [selectedModels[0]] : [''];
} }
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '')
) {
selectedModels = normalizeSelectedModels(selectedModels);
noteChatDebug('normalized empty selected models after load', { selectedModels });
}
oldSelectedModelIds = structuredClone(selectedModels); oldSelectedModelIds = structuredClone(selectedModels);
@@ -1721,6 +1855,7 @@
chatTitle.set(chatContent.title); chatTitle.set(chatContent.title);
params = structuredClone(chatContent?.params ?? {}); params = structuredClone(chatContent?.params ?? {});
delete params.note_id;
chatFiles = structuredClone(chatContent?.files ?? []); chatFiles = structuredClone(chatContent?.files ?? []);
// Load tasks from chat-level DB field // Load tasks from chat-level DB field
@@ -1748,13 +1883,25 @@
// If the response is already done, remaining tasks are just background // If the response is already done, remaining tasks are just background
// work (follow-ups, title gen) that shouldn't block the input. // work (follow-ups, title gen) that shouldn't block the input.
const activeTaskIds = taskIds; const activeTaskIds = taskIds;
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId) const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
.then((res) => res?.task_ids ?? []) .then((res) => res?.task_ids ?? [])
.catch(() => []); .catch((error) => {
console.warn('[note-chat] getTaskIdsByChatId failed; continuing without tasks', {
chatIdProp,
activeChatId: $chatId,
error
});
return [];
});
noteChatDebug('task reconciliation completed', {
pendingTaskCount: pendingTaskIds.length,
hasCurrentMessage: !!currentMessage
});
if (taskIds !== activeTaskIds) { if (taskIds !== activeTaskIds) {
noteChatDebug('task ids changed during load; aborting stale load');
return; return;
} }
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
const responseComplete = currentMessage?.role === 'assistant' && currentMessage?.done; const responseComplete = currentMessage?.role === 'assistant' && currentMessage?.done;
if (pendingTaskIds.length > 0 && !responseComplete) { if (pendingTaskIds.length > 0 && !responseComplete) {
@@ -1771,9 +1918,18 @@
return true; return true;
} else { } else {
console.warn('[note-chat] chat response missing chat payload', {
chatIdProp,
activeChatId: $chatId,
chat
});
return null; return null;
} }
} }
console.warn('[note-chat] no chat returned from getChatById', {
chatIdProp,
activeChatId: $chatId
});
}; };
const scrollToBottom = async (behavior = 'auto') => { const scrollToBottom = async (behavior = 'auto') => {
@@ -2298,8 +2454,10 @@
); );
if (result?.id) { if (result?.id) {
await goto(`/c/${result.id}`); if (!embedded) {
await refreshChatList(localStorage.token, { refreshPinned: true }); await goto(`/c/${result.id}`);
await refreshChatList(localStorage.token, { refreshPinned: true });
}
toast.success($i18n.t('Chat forked'), { id: toastId }); toast.success($i18n.t('Chat forked'), { id: toastId });
} else { } else {
toast.error($i18n.t('Failed to fork chat'), { id: toastId }); toast.error($i18n.t('Failed to fork chat'), { id: toastId });
@@ -2857,7 +3015,7 @@
// and causing spurious toast notifications / state duplication). // and causing spurious toast notifications / state duplication).
if (res.chat_id && $chatId !== res.chat_id && $chatId === _chatId) { if (res.chat_id && $chatId !== res.chat_id && $chatId === _chatId) {
await chatId.set(res.chat_id); await chatId.set(res.chat_id);
if (!$temporaryChatEnabled) { if (!$temporaryChatEnabled && !embedded) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`); window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
await refreshChatList(localStorage.token); await refreshChatList(localStorage.token);
@@ -3134,11 +3292,15 @@
_chatId = chat.id; _chatId = chat.id;
await chatId.set(_chatId); await chatId.set(_chatId);
window.history.replaceState(history.state, '', `/c/${_chatId}`); if (!embedded) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
}
await tick(); await tick();
await refreshChatList(localStorage.token); if (!embedded) {
await refreshChatList(localStorage.token);
}
selectedFolder.set(null); selectedFolder.set(null);
} else { } else {
@@ -3340,14 +3502,17 @@
/> />
<div <div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar class="{embedded
? 'h-full'
: 'h-screen max-h-[100dvh]'} transition-width duration-200 ease-in-out {$showSidebar &&
!embedded
? ' md:max-w-[calc(100%-var(--sidebar-width))]' ? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '} w-full max-w-full flex flex-col" : ' '} w-full max-w-full flex flex-col"
id="chat-container" id={chatContainerId}
> >
{#if !loading} {#if !loading}
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col"> <div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
{#if $selectedFolder && $selectedFolder?.meta?.background_image_url} {#if !embedded && $selectedFolder && $selectedFolder?.meta?.background_image_url}
<div <div
class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat" class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$selectedFolder?.meta?.background_image_url}) " style="background-image: url({$selectedFolder?.meta?.background_image_url}) "
@@ -3356,7 +3521,7 @@
<div <div
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0" class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/> />
{:else if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null} {:else if !embedded && ($settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null)}
<div <div
class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat" class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$settings?.backgroundImageUrl ?? style="background-image: url({$settings?.backgroundImageUrl ??
@@ -3371,66 +3536,86 @@
<PaneGroup direction="horizontal" class="w-full h-full"> <PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col"> <Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<Navbar {#if embedded}
bind:this={navbarElement} <div
{readOnly} 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"
chat={{ >
id: $chatId, <div class="min-w-0 truncate text-[13px] font-medium">
chat: { {embeddedHeaderTitle}
title: $chatTitle, </div>
models: selectedModels, <Tooltip content={$i18n.t('Close')} placement="bottom">
system: $settings.system ?? undefined, <button
params: params, type="button"
history: history, 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"
timestamp: Date.now() on:click={() => onCloseEmbedded?.()}
} aria-label={$i18n.t('Close')}
}} >
{history} <XMark className="size-4" strokeWidth="2" />
title={$chatTitle} </button>
shareEnabled={!!history.currentId} </Tooltip>
{initNewChat} </div>
scrollToTop={!isNearTop ? scrollToTop : null} {:else}
{archiveChatHandler} <Navbar
{deleteChatHandler} bind:this={navbarElement}
{moveChatHandler} {readOnly}
onSaveTempChat={async () => { chat={{
try { id: $chatId,
if (!history?.currentId || !Object.keys(history.messages).length) { chat: {
toast.error($i18n.t('No conversation to save')); title: $chatTitle,
return; models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
timestamp: Date.now()
} }
const messages = createMessagesList(history, history.currentId); }}
const title = {history}
messages.find((m) => m.role === 'user')?.content ?? $i18n.t('New Chat'); title={$chatTitle}
shareEnabled={!!history.currentId}
{initNewChat}
scrollToTop={!isNearTop ? scrollToTop : null}
{archiveChatHandler}
{deleteChatHandler}
{moveChatHandler}
onSaveTempChat={async () => {
try {
if (!history?.currentId || !Object.keys(history.messages).length) {
toast.error($i18n.t('No conversation to save'));
return;
}
const messages = createMessagesList(history, history.currentId);
const title =
messages.find((m) => m.role === 'user')?.content ?? $i18n.t('New Chat');
const savedChat = await createNewChat( const savedChat = await createNewChat(
localStorage.token, localStorage.token,
{ {
id: uuidv4(), id: uuidv4(),
title: title.length > 50 ? `${title.slice(0, 50)}...` : title, title: title.length > 50 ? `${title.slice(0, 50)}...` : title,
models: selectedModels, models: selectedModels,
params: params, params: params,
history: history, history: history,
messages: messages, messages: messages,
timestamp: Date.now() timestamp: Date.now()
}, },
null null
); );
if (savedChat) { if (savedChat) {
temporaryChatEnabled.set(false); temporaryChatEnabled.set(false);
chatId.set(savedChat.id); chatId.set(savedChat.id);
await refreshChatList(localStorage.token); await refreshChatList(localStorage.token);
await goto(`/c/${savedChat.id}`); await goto(`/c/${savedChat.id}`);
toast.success($i18n.t('Conversation saved successfully')); toast.success($i18n.t('Conversation saved successfully'));
}
} catch (error) {
console.error('Error saving conversation:', error);
toast.error($i18n.t('Failed to save conversation'));
} }
} catch (error) { }}
console.error('Error saving conversation:', 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"> <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} {#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
@@ -3458,6 +3643,7 @@
}} }}
bind:selectedModels bind:selectedModels
{atSelectedModel} {atSelectedModel}
className={embedded ? 'h-full flex pt-4' : 'h-full flex pt-18'}
{sendMessage} {sendMessage}
{showMessage} {showMessage}
{submitMessage} {submitMessage}
@@ -3466,9 +3652,10 @@
{mergeResponses} {mergeResponses}
{chatActionHandler} {chatActionHandler}
{addMessages} {addMessages}
topPadding={true} topPadding={!embedded}
bottomPadding={files.length > 0} bottomPadding={files.length > 0}
{onSelect} {onSelect}
{onInsertToNote}
/> />
</div> </div>
</div> </div>
@@ -3557,7 +3744,7 @@
if (e.detail || files.length > 0) { if (e.detail || files.length > 0) {
await tick(); await tick();
submitHandler(e.detail); submitHandler(withSelectedText(e.detail));
} }
}} }}
/> />
@@ -3569,6 +3756,68 @@
</div> </div>
</div> </div>
{/if} {/if}
{:else if embedded}
<div class="flex h-full min-h-0 flex-col justify-end">
<div class="flex flex-1 items-end px-5 pb-3">
<div class="w-full">
<div class="mb-2 text-[12px] text-gray-400 dark:text-gray-500">
{$i18n.t('Suggested prompts')}
</div>
<div class="flex flex-col">
{#each embeddedSuggestedPrompts as suggestion}
<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)}
>
<span class="min-w-0 truncate">{$i18n.t(suggestion)}</span>
</button>
{/each}
</div>
</div>
</div>
<div class="pb-2 z-10">
<MessageInput
bind:this={messageInput}
{history}
{taskIds}
bind:selectedModels
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
{pendingOAuthTools}
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
bind:dragged
chatId={$chatId}
{contextUsage}
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{generating}
{stopResponse}
{createMessagePair}
{onUpload}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
onWebSearchToggle={handleWebSearchToggle}
on:submit={async (e) => {
clearDraft($chatId);
if (e.detail || files.length > 0) {
await tick();
submitHandler(withSelectedText(e.detail));
}
}}
/>
</div>
</div>
{:else} {:else}
<div class="flex items-center h-full"> <div class="flex items-center h-full">
<Placeholder <Placeholder
@@ -3603,7 +3852,7 @@
clearDraft(); clearDraft();
if (e.detail || files.length > 0) { if (e.detail || files.length > 0) {
await tick(); await tick();
submitHandler(e.detail); submitHandler(withSelectedText(e.detail));
} }
}} }}
/> />
@@ -3612,28 +3861,31 @@
</div> </div>
</Pane> </Pane>
<ChatControls {#if !embedded}
bind:this={controlPaneComponent} <ChatControls
bind:history bind:this={controlPaneComponent}
bind:chatFiles bind:history
bind:params bind:chatFiles
bind:files bind:params
bind:pane={controlPane} bind:files
chatId={$chatId} bind:pane={controlPane}
modelId={selectedModelIds?.at(0) ?? null} chatId={$chatId}
models={selectedModelIds.reduce((a, e, i, arr) => { modelId={selectedModelIds?.at(0) ?? null}
const model = $models.find((m) => m.id === e); models={selectedModelIds.reduce((a, e, i, arr) => {
if (model) { const model = $models.find((m) => m.id === e);
return [...a, model]; if (model) {
} return [...a, model];
return a; }
}, [])} return a;
submitPrompt={submitHandler} }, [])}
{stopResponse} submitPrompt={submitHandler}
{showMessage} {stopResponse}
{eventTarget} {showMessage}
{codeInterpreterEnabled} {eventTarget}
/> {codeInterpreterEnabled}
containerId={chatContainerId}
/>
{/if}
</PaneGroup> </PaneGroup>
</div> </div>
{:else if loading} {:else if loading}
+4 -3
View File
@@ -55,6 +55,7 @@
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
export let pane: Pane | null = null; export let pane: Pane | null = null;
export let containerId = 'chat-container';
let largeScreen = false; let largeScreen = false;
let dragged = false; let dragged = false;
@@ -159,7 +160,7 @@
export const openPane = () => { export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) { if (parseInt(localStorage?.chatControlsSize)) {
const container = document.getElementById('chat-container'); const container = document.getElementById(containerId);
let size = Math.floor( let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100 (parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
); );
@@ -218,7 +219,7 @@
paneReady = true; paneReady = true;
}, 0); }, 0);
const container = document.getElementById('chat-container') as HTMLElement; const container = document.getElementById(containerId) as HTMLElement;
if (!container) return; if (!container) return;
minSize = Math.floor((350 / container.clientWidth) * 100); minSize = Math.floor((350 / container.clientWidth) * 100);
@@ -407,7 +408,7 @@
if (size < minSize) { if (size < minSize) {
localStorage.chatControlsSize = 0; localStorage.chatControlsSize = 0;
} else { } else {
const container = document.getElementById('chat-container'); const container = document.getElementById(containerId);
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth); localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
} }
} }
+2
View File
@@ -49,6 +49,7 @@
export let autoScroll; export let autoScroll;
export let onSelect = (e) => {}; export let onSelect = (e) => {};
export let onInsertToNote: ((content: string) => void) | null = null;
export let messagesCount: number | null = 8; export let messagesCount: number | null = 8;
let messagesLoading = false; let messagesLoading = false;
@@ -542,6 +543,7 @@
{readOnly} {readOnly}
{editCodeBlock} {editCodeBlock}
{topPadding} {topPadding}
{onInsertToNote}
/> />
{/each} {/each}
</ul> </ul>
@@ -43,6 +43,7 @@
export let readOnly = false; export let readOnly = false;
export let editCodeBlock = true; export let editCodeBlock = true;
export let topPadding = false; export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
</script> </script>
<div <div
@@ -72,6 +73,7 @@
{readOnly} {readOnly}
{editCodeBlock} {editCodeBlock}
{topPadding} {topPadding}
{onInsertToNote}
/> />
{:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1} {:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1}
<ResponseMessage <ResponseMessage
@@ -123,6 +125,7 @@
{readOnly} {readOnly}
{editCodeBlock} {editCodeBlock}
{topPadding} {topPadding}
{onInsertToNote}
/> />
{/key} {/key}
{/if} {/if}
@@ -49,6 +49,7 @@
export let triggerScroll: Function; export let triggerScroll: Function;
export let topPadding = false; export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -320,6 +321,7 @@
{addMessages} {addMessages}
{readOnly} {readOnly}
{topPadding} {topPadding}
{onInsertToNote}
/> />
{/if} {/if}
{/key} {/key}
@@ -377,6 +379,7 @@
{readOnly} {readOnly}
{editCodeBlock} {editCodeBlock}
{topPadding} {topPadding}
{onInsertToNote}
/> />
{/if} {/if}
{/key} {/key}
@@ -411,7 +414,9 @@
</div> </div>
{#if message.timestamp} {#if message.timestamp}
<div class="mt-0.5 flex justify-start whitespace-nowrap text-gray-600 dark:text-gray-500"> <div
class="mt-0.5 flex justify-start whitespace-nowrap text-gray-600 dark:text-gray-500"
>
<Tooltip <Tooltip
className="flex self-center" className="flex self-center"
content={formatMessageTimestampFull(message.timestamp * 1000)} content={formatMessageTimestampFull(message.timestamp * 1000)}
@@ -166,6 +166,7 @@
export let readOnly = false; export let readOnly = false;
export let editCodeBlock = true; export let editCodeBlock = true;
export let topPadding = false; export let topPadding = false;
export let onInsertToNote: ((content: string) => void) | null = null;
let citationsElement: HTMLDivElement; let citationsElement: HTMLDivElement;
@@ -1051,6 +1052,22 @@
</button> </button>
</Tooltip> </Tooltip>
{#if onInsertToNote && visibleResponseContent}
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
<button
aria-label={$i18n.t('Insert into note')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} rounded-lg px-2 py-1.5 text-xs text-gray-500 transition hover:bg-black/5 hover:text-black dark:hover:bg-white/5 dark:hover:text-white"
on:click={() => {
onInsertToNote?.(visibleResponseContent);
}}
>
{$i18n.t('Insert')}
</button>
</Tooltip>
{/if}
{#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))} {#if !readOnly && ($user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true))}
<Tooltip content={$i18n.t('Read Aloud')} placement="bottom"> <Tooltip content={$i18n.t('Read Aloud')} placement="bottom">
<button <button
@@ -11,7 +11,6 @@
import ChevronUp from '../icons/ChevronUp.svelte'; import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte'; import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import WrenchSolid from '../icons/WrenchSolid.svelte'; import WrenchSolid from '../icons/WrenchSolid.svelte';
import CheckCircle from '../icons/CheckCircle.svelte'; import CheckCircle from '../icons/CheckCircle.svelte';
import Image from './Image.svelte'; import Image from './Image.svelte';
@@ -154,19 +153,9 @@
<!-- Full label (md and above) --> <!-- Full label (md and above) -->
<span class="hidden @md:inline font-normal"> <span class="hidden @md:inline font-normal">
{#if isDone} {#if isDone}
<Markdown {$i18n.t('View Result from {{NAME}}', { NAME: attributes.name })}
id={`${componentId}-tool-call-title`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else} {:else}
<Markdown {$i18n.t('Executing {{NAME}}...', { NAME: attributes.name })}
id={`${componentId}-tool-call-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if} {/if}
</span> </span>
</div> </div>
@@ -174,9 +163,9 @@
<!-- Chevron --> <!-- Chevron -->
<div class="flex shrink-0 self-center translate-y-[1px]"> <div class="flex shrink-0 self-center translate-y-[1px]">
{#if open} {#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" /> <ChevronUp strokeWidth="3.5" className="size-3" />
{:else} {:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" /> <ChevronDown strokeWidth="3.5" className="size-3" />
{/if} {/if}
</div> </div>
</div> </div>
@@ -184,7 +173,7 @@
{#if open} {#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}> <div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<div class="border border-gray-50 dark:border-gray-850/30 rounded-2xl my-1.5 p-3 space-y-3"> <div class="border border-gray-50 dark:border-gray-850/30 rounded-2xl my-1.5 p-2.5 space-y-2">
<!-- Input --> <!-- Input -->
{#if args} {#if args}
<div> <div>
@@ -210,7 +199,7 @@
{:else} {:else}
<div class="tool-call-body w-full max-w-none!"> <div class="tool-call-body w-full max-w-none!">
<pre <pre
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2.5 overflow-x-auto">{formatJSONString( class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2 overflow-x-auto">{formatJSONString(
args args
)}</pre> )}</pre>
</div> </div>
@@ -229,7 +218,7 @@
<div class="w-full max-w-none!"> <div class="w-full max-w-none!">
{#if typeof parsedResult === 'object' && parsedResult !== null} {#if typeof parsedResult === 'object' && parsedResult !== null}
<pre <pre
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2.5 overflow-x-auto">{JSON.stringify( class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2 overflow-x-auto">{JSON.stringify(
parsedResult, parsedResult,
null, null,
2 2
+117 -335
View File
@@ -23,10 +23,10 @@
import { PaneGroup, Pane, PaneResizer } from 'paneforge'; import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { compressImage, copyToClipboard, splitStream, convertHeicToJpeg } from '$lib/utils'; import { compressImage, copyToClipboard, convertHeicToJpeg } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_BASE_URL } from '$lib/constants';
import { getFileById, uploadFile } from '$lib/apis/files'; import { getFileById, uploadFile } from '$lib/apis/files';
import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai'; import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { import {
config, config,
@@ -42,8 +42,7 @@
import { downloadPdf } from './utils'; import { downloadPdf } from './utils';
import Controls from './NoteEditor/Controls.svelte'; import Chat from '$lib/components/chat/Chat.svelte';
import Chat from './NoteEditor/Chat.svelte';
import NotePanel from '$lib/components/notes/NotePanel.svelte'; import NotePanel from '$lib/components/notes/NotePanel.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte'; import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
@@ -65,6 +64,7 @@
import { import {
deleteNoteById, deleteNoteById,
getNoteById, getNoteById,
getNoteChatById,
updateNoteById, updateNoteById,
updateNoteAccessGrants, updateNoteAccessGrants,
toggleNotePinnedStatusById, toggleNotePinnedStatusById,
@@ -76,28 +76,18 @@
import Mic from '../icons/Mic.svelte'; import Mic from '../icons/Mic.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
import Calendar from '../icons/Calendar.svelte';
import Users from '../icons/Users.svelte';
import AccessButton from '../common/AccessButton.svelte'; import AccessButton from '../common/AccessButton.svelte';
import Image from '../common/Image.svelte';
import FileItem from '../common/FileItem.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import RecordMenu from './RecordMenu.svelte'; import RecordMenu from './RecordMenu.svelte';
import NoteMenu from './Notes/NoteMenu.svelte'; import NoteMenu from './Notes/NoteMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import Sparkles from '../icons/Sparkles.svelte'; import Sparkles from '../icons/Sparkles.svelte';
import SparklesSolid from '../icons/SparklesSolid.svelte';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import Bars3BottomLeft from '../icons/Bars3BottomLeft.svelte';
import ArrowUturnLeft from '../icons/ArrowUturnLeft.svelte'; import ArrowUturnLeft from '../icons/ArrowUturnLeft.svelte';
import ArrowUturnRight from '../icons/ArrowUturnRight.svelte'; import ArrowUturnRight from '../icons/ArrowUturnRight.svelte';
import Sidebar from '../icons/Sidebar.svelte'; import Sidebar from '../icons/Sidebar.svelte';
import ArrowRight from '../icons/ArrowRight.svelte';
import Cog6 from '../icons/Cog6.svelte';
import AiMenu from './AIMenu.svelte';
import AdjustmentsHorizontalOutline from '../icons/AdjustmentsHorizontalOutline.svelte'; import AdjustmentsHorizontalOutline from '../icons/AdjustmentsHorizontalOutline.svelte';
export let id: null | string = null; export let id: null | string = null;
@@ -131,7 +121,6 @@
); );
let files = []; let files = [];
let messages = [];
let wordCount = 0; let wordCount = 0;
let charCount = 0; let charCount = 0;
@@ -142,8 +131,9 @@
let recording = false; let recording = false;
let displayMediaRecord = false; let displayMediaRecord = false;
let showPanel = false; let showNoteChat = false;
let selectedPanel = 'chat'; let noteChatId = null;
let noteChatLoading = false;
let selectedContent = null; let selectedContent = null;
@@ -157,11 +147,6 @@
let dragged = false; let dragged = false;
let loading = false; let loading = false;
let editing = false;
let streaming = false;
let stopResponseFlag = false;
let inputElement = null; let inputElement = null;
// Computed HTML for editor: fall back to markdown if HTML is missing // Computed HTML for editor: fall back to markdown if HTML is missing
@@ -176,8 +161,6 @@
return null; return null;
}); });
messages = [];
if (res) { if (res) {
note = res; note = res;
if (!Array.isArray(note?.access_grants)) { if (!Array.isArray(note?.access_grants)) {
@@ -249,11 +232,6 @@
return false; return false;
} }
const onEdited = async () => {
if (!editor) return;
editor.commands.setContent(note.data.content.html);
};
const generateTitleHandler = async () => { const generateTitleHandler = async () => {
const content = note.data.content.md; const content = note.data.content.md;
const DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = `### Task: const DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = `### Task:
@@ -328,34 +306,6 @@ ${content}
changeDebounceHandler(); changeDebounceHandler();
}; };
async function enhanceNoteHandler() {
if (selectedModelId === '') {
toast.error($i18n.t('Please select a model.'));
return;
}
const model = $models
.filter((model) => model.id === selectedModelId && !(model?.info?.meta?.hidden ?? false))
.find((model) => model.id === selectedModelId);
if (!model) {
selectedModelId = '';
return;
}
editing = true;
await enhanceCompletionHandler(model);
editing = false;
onEdited();
versionIdx = null;
}
const stopResponseHandler = async () => {
stopResponseFlag = true;
console.log('stopResponse', stopResponseFlag);
};
function setContentByVersion(versionIdx) { function setContentByVersion(versionIdx) {
if (!note.data.versions?.length) return; if (!note.data.versions?.length) return;
let idx = versionIdx; let idx = versionIdx;
@@ -433,13 +383,6 @@ ${content}
files = [...files, fileItem]; files = [...files, fileItem];
// open the settings panel if it is not open
selectedPanel = 'settings';
if (!showPanel) {
showPanel = true;
}
try { try {
// If the file is an audio file, provide the language for STT. // If the file is an audio file, provide the language for STT.
let metadata = null; let metadata = null;
@@ -597,6 +540,51 @@ ${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,
alreadyLoading: noteChatLoading,
currentChatId: noteChatId,
sidebarOpen: showNoteChat
});
if (!note?.id || noteChatLoading) return;
noteChatLoading = true;
const chat = await getNoteChatById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] open failed', { noteId: note?.id, error });
toast.error(`${error}`);
return null;
});
noteChatLoading = false;
if (chat?.id) {
console.info('[note-chat] open resolved', {
noteId: note.id,
chatId: chat.id,
title: chat.title,
hasChatPayload: !!chat.chat
});
noteChatId = chat.id;
showNoteChat = true;
} else {
console.warn('[note-chat] open returned no chat id', { noteId: note.id, chat });
}
};
const downloadHandler = async (type) => { const downloadHandler = async (type) => {
console.log('downloadHandler', type); console.log('downloadHandler', type);
if (type === 'txt') { if (type === 'txt') {
@@ -629,113 +617,6 @@ ${content}
} }
}; };
const scrollToBottom = () => {
const element = document.getElementById('note-content-container');
if (element) {
element.scrollTop = element?.scrollHeight;
}
};
const enhanceCompletionHandler = async (model) => {
stopResponseFlag = false;
let enhancedContent = {
json: null,
html: '',
md: ''
};
const systemPrompt = `Enhance existing notes using additional context provided from audio transcription or uploaded file content in the content's primary language. Your task is to make the notes more useful and comprehensive by incorporating relevant information from the provided context.
Input will be provided within <notes> and <context> XML tags, providing a structure for the existing notes and context respectively.
# Output Format
Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, and emphasis to improve clarity and presentation. Ensure that all integrated content from the context is accurately reflected. Return only the markdown formatted note.
`;
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content:
`<notes>${note.data.content.md}</notes>` +
(files && files.length > 0
? `\n<context>${files.map((file) => `${file.name}: ${file?.file?.data?.content ?? 'Could not extract content'}\n`).join('')}</context>`
: '')
}
]
},
`${WEBUI_BASE_URL}/api`
);
await tick();
streaming = true;
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
controller.abort('User: Stop Response');
}
editing = false;
streaming = false;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
console.log(line);
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (data.choices && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.delta && choice.delta.content) {
enhancedContent.md += choice.delta.content;
enhancedContent.html = marked.parse(enhancedContent.md);
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;
note.data.content.json = null;
scrollToBottom();
}
}
}
}
}
} catch (error) {
console.log(error);
}
}
}
streaming = false;
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@@ -800,6 +681,18 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
note.data.files = files; note.data.files = files;
} }
if (_note.data?.content) {
note.data.content = {
...note.data.content,
..._note.data.content
};
if (editor) {
editor.commands.setContent(
_note.data.content.html || marked.parse(_note.data.content.md ?? '')
);
}
}
if (_note.title && _note.title) { if (_note.title && _note.title) {
note.title = _note.title; note.title = _note.title;
} }
@@ -1025,44 +918,19 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
</div> </div>
{/if} {/if}
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'chat') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'chat';
}
}}
>
<ChatBubbleOval />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'settings') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'settings';
}
}}
>
<AdjustmentsHorizontalOutline />
</button>
</Tooltip>
{/if} {/if}
<Tooltip content={$i18n.t('Controls')} placement="top">
<button
type="button"
class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg"
aria-label={$i18n.t('Controls')}
on:click={openNoteChat}
>
<AdjustmentsHorizontalOutline className="size-4" />
</button>
</Tooltip>
{#if note?.write_access} {#if note?.write_access}
<RecordMenu <RecordMenu
onRecord={async () => { onRecord={async () => {
@@ -1150,6 +1018,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
onDelete={() => { onDelete={() => {
showDeleteConfirm = true; showDeleteConfirm = true;
}} }}
onChat={openNoteChat}
isPinned={$pinnedNotes.some((n) => n.id === note.id)} isPinned={$pinnedNotes.some((n) => n.id === note.id)}
onPin={async () => { onPin={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id); await toggleNotePinnedStatusById(localStorage.token, note.id);
@@ -1239,14 +1108,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
class=" flex-1 w-full h-full overflow-auto px-2.5 relative" class=" flex-1 w-full h-full overflow-auto px-2.5 relative"
id="note-content-container" id="note-content-container"
> >
{#if editing}
<div
class="w-full h-full fixed top-0 left-0 {streaming
? ''
: ' backdrop-blur-xs bg-white/10 dark:bg-gray-900/10'} flex items-center justify-center z-10 cursor-not-allowed"
></div>
{/if}
<RichTextInput <RichTextInput
bind:this={inputElement} bind:this={inputElement}
bind:editor bind:editor
@@ -1264,7 +1125,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
image={true} image={true}
{files} {files}
placeholder={$i18n.t('Write something...')} placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && !editing && note?.write_access} editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => { onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' '); const selectedText = editor.state.doc.textBetween(from, to, ' ');
@@ -1349,126 +1210,47 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
{/if} {/if}
</div> </div>
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none"> {#if recording}
<div class="flex flex-col gap-2 justify-end"> <div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
{#if recording} <div class="flex-1 w-full">
<div class="flex-1 w-full"> <VoiceRecording
<VoiceRecording bind:recording
bind:recording className="p-1 w-full max-w-full"
className="p-1 w-full max-w-full" transcribe={false}
transcribe={false} displayMedia={displayMediaRecord}
displayMedia={displayMediaRecord} echoCancellation={false}
echoCancellation={false} noiseSuppression={false}
noiseSuppression={false} onCancel={() => {
onCancel={() => { recording = false;
recording = false; displayMediaRecord = false;
displayMediaRecord = false; }}
}} onConfirm={(data) => {
onConfirm={(data) => { if (data?.file) {
if (data?.file) { uploadFileHandler(data?.file);
uploadFileHandler(data?.file);
}
recording = false;
displayMediaRecord = false;
}}
/>
</div>
{:else}
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
{/if}
</div>
</div>
</Pane>
<NotePanel bind:show={showPanel}>
{#if selectedPanel === 'chat'}
<Chat
bind:show={showPanel}
bind:selectedModelId
bind:messages
bind:note
bind:editing
bind:streaming
bind:stopResponseFlag
{editor}
{inputElement}
{selectedContent}
{files}
onInsert={insertHandler}
onStop={stopResponseHandler}
{onEdited}
insertNoteHandler={() => {
insertNoteVersion(note);
}}
scrollToBottomHandler={scrollToBottom}
/>
{:else if selectedPanel === 'settings'}
<Controls
bind:show={showPanel}
bind:selectedModelId
bind:files
onUpdate={(updatedFiles) => {
files = updatedFiles;
note.data.files = files.length > 0 ? files : null;
if (editor) {
editor.storage.files = files;
const fileIds = new Set(files.map((file) => file.id));
const ranges = [];
editor.state.doc.descendants((node, pos) => {
const src = node.attrs.src;
if (
node.type.name === 'image' &&
src?.startsWith('data://') &&
!fileIds.has(src.slice('data://'.length))
) {
ranges.push([pos, pos + node.nodeSize]);
} }
});
if (ranges.length > 0) { recording = false;
let transaction = editor.state.tr; displayMediaRecord = false;
ranges.reverse().forEach(([from, to]) => { }}
transaction = transaction.delete(from, to); />
}); </div>
editor.view.dispatch(transaction); </div>
} {/if}
} </Pane>
<NotePanel bind:show={showNoteChat}>
changeDebounceHandler(); {#if noteChatLoading}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if noteChatId}
<Chat
embedded={true}
chatIdProp={noteChatId}
initialFiles={getNoteChatFiles()}
selectedText={selectedContent?.text ?? ''}
onInsertToNote={insertHandler}
onCloseEmbedded={() => {
showNoteChat = false;
}} }}
/> />
{/if} {/if}
@@ -1,445 +0,0 @@
<script lang="ts">
export let show = false;
export let selectedModelId = '';
import { marked } from 'marked';
// Configure marked with extensions
marked.use({
breaks: true,
gfm: true,
renderer: {
list(body, ordered, start) {
const isTaskList = body.includes('data-checked=');
if (isTaskList) {
return `<ul data-type="taskList">${body}</ul>`;
}
const type = ordered ? 'ol' : 'ul';
const startatt = ordered && start !== 1 ? ` start="${start}"` : '';
return `<${type}${startatt}>${body}</${type}>`;
},
listitem(text, task, checked) {
if (task) {
const checkedAttr = checked ? 'true' : 'false';
return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
}
return `<li>${text}</li>`;
}
}
});
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, tick, getContext } from 'svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
import { chatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import Messages from '$lib/components/notes/NoteEditor/Chat/Messages.svelte';
import MessageInput from '$lib/components/channel/MessageInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import PencilSquare from '$lib/components/icons/PencilSquare.svelte';
const i18n = getContext('i18n');
export let editor = null;
export let editing = false;
export let streaming = false;
export let stopResponseFlag = false;
export let note = null;
export let selectedContent = null;
export let files = [];
export let messages = [];
export let onInsert = (content) => {};
export let onStop = () => {};
export let onEdited = () => {};
export let insertNoteHandler = () => {};
export let scrollToBottomHandler = () => {};
let loaded = false;
let loading = false;
let messagesContainerElement: HTMLDivElement;
let system = '';
let editEnabled = false;
let chatInputElement = null;
const DEFAULT_DOCUMENT_EDITOR_PROMPT = `You are an expert document editor.
## Task
Based on the user's instruction, update and enhance the existing notes or selection by incorporating relevant and accurate information from the provided context in the content's primary language. Ensure all edits strictly follow the user’s intent.
## Input Structure
- Existing notes: Enclosed within <notes></notes> XML tags.
- Additional context: Enclosed within <context></context> XML tags.
- Current note selection: Enclosed within <selection></selection> XML tags.
- Editing instruction: Provided in the user message.
## Output Instructions
- If a selection is provided, edit **only** the content within <selection></selection>. Leave unselected parts unchanged.
- If no selection is provided, edit the entire notes.
- Deliver a single, rewritten version of the notes in markdown format.
- Integrate information from the context only if it directly supports the user's instruction.
- Use clear, organized markdown elements: headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, bold and italic text as appropriate.
- Focus on improving clarity, completeness, and usefulness of the notes.
- Return only the final, fully-edited markdown notes—do not include explanations, reasoning, or XML tags.
`;
let scrolledToBottom = true;
const scrollToBottom = () => {
if (messagesContainerElement) {
if (scrolledToBottom) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}
};
const onScroll = () => {
if (messagesContainerElement) {
scrolledToBottom =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 10;
}
};
const chatCompletionHandler = async () => {
if (selectedModelId === '') {
toast.error($i18n.t('Please select a model.'));
return;
}
const model = $models.find((model) => model.id === selectedModelId);
if (!model) {
selectedModelId = '';
return;
}
let responseMessage;
if (messages.at(-1)?.role === 'assistant') {
responseMessage = messages.at(-1);
} else {
responseMessage = {
role: 'assistant',
content: '',
done: false
};
messages.push(responseMessage);
messages = messages;
}
await tick();
scrollToBottom();
stopResponseFlag = false;
let enhancedContent = {
json: null,
html: '',
md: ''
};
system = '';
if (editEnabled) {
system = `${DEFAULT_DOCUMENT_EDITOR_PROMPT}\n\n`;
} else {
system = `You are a helpful assistant. Please answer the user's questions based on the context provided.\n\n`;
}
system +=
`<notes>${note?.data?.content?.md ?? ''}</notes>` +
(files && files.length > 0
? `\n<context>${files.map((file) => `${file.name}: ${file?.file?.data?.content ?? 'Could not extract content'}\n`).join('')}</context>`
: '') +
(selectedContent ? `\n<selection>${selectedContent?.text}</selection>` : '');
// Filter out empty assistant placeholder messages to avoid sending
// an empty trailing assistant message as "response prefill", which is
// incompatible with enable_thinking in llama.cpp and similar backends.
const filteredMessages = messages.filter((m) => !(m.role === 'assistant' && m.content === ''));
const chatMessages = JSON.parse(
JSON.stringify([
{
role: 'system',
content: `${system}`
},
...filteredMessages
])
);
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: chatMessages.map((m) => ({
role: m.role,
content: m.content
}))
// ...(files && files.length > 0 ? { files } : {}) // TODO: Decide whether to use native file handling or not
},
`${WEBUI_BASE_URL}/api`
);
await tick();
scrollToBottom();
let messageContent = '';
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(splitStream('\n'))
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || stopResponseFlag) {
if (stopResponseFlag) {
controller.abort('User: Stop Response');
}
if (editEnabled) {
editing = false;
streaming = false;
onEdited();
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
if (editEnabled) {
responseMessage.content = `<status title="${$i18n.t('Edited')}" done="true" />`;
if (selectedContent && selectedContent?.text && editor) {
editor.commands.insertContentAt(
{
from: selectedContent.from,
to: selectedContent.to
},
enhancedContent.html || enhancedContent.md || ''
);
selectedContent = null;
}
}
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
let deltaContent = data.choices[0]?.delta?.content ?? '';
if (responseMessage.content == '' && deltaContent == '\n') {
continue;
} else {
if (editEnabled) {
editing = true;
streaming = true;
enhancedContent.md += deltaContent;
enhancedContent.html = marked.parse(enhancedContent.md);
if (!selectedContent || !selectedContent?.text) {
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;
note.data.content.json = null;
}
scrollToBottomHandler();
responseMessage.content = `<status title="${$i18n.t('Editing')}" done="false" />`;
messages = messages;
} else {
messageContent += deltaContent;
responseMessage.content = messageContent;
messages = messages;
}
await tick();
}
}
}
}
} catch (error) {
console.log(error);
}
scrollToBottom();
}
}
};
const submitHandler = async (e) => {
const { content, data } = e;
if (selectedModelId && content) {
messages.push({
role: 'user',
content: content
});
messages = messages;
await tick();
scrollToBottom();
loading = true;
await chatCompletionHandler();
messages = messages.map((message) => {
message.done = true;
return message;
});
loading = false;
stopResponseFlag = false;
}
};
onMount(async () => {
editEnabled = localStorage.getItem('noteEditEnabled') === 'true';
loaded = true;
await tick();
scrollToBottom();
});
</script>
<div class="flex items-center mb-1.5 pt-1.5">
<div class="flex items-center mr-1">
<button
class="p-0.5 bg-transparent transition rounded-lg"
on:click={() => {
show = !show;
}}
>
<XMark className="size-5" strokeWidth="2.5" />
</button>
</div>
<div class=" font-normal text-base flex items-center gap-1">
<div>
{$i18n.t('Chat')}
</div>
<div>
<Tooltip
content={$i18n.t(
'This feature is experimental and may be modified or discontinued without notice.'
)}
position="top"
className="inline-block"
>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
</Tooltip>
</div>
</div>
</div>
<div class="flex flex-col items-center flex-1 @container">
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full relative">
<div class=" flex flex-col h-full">
<div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 scrollbar-hidden"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={onScroll}
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
<Messages bind:messages {onInsert} />
</div>
</div>
</div>
<div class=" pb-[1rem]">
{#if selectedContent}
<div class="text-xs rounded-xl px-2.5 py-3 w-full markdown-prose-xs">
<blockquote>
<div class=" line-clamp-3">
{selectedContent?.text}
</div>
</blockquote>
</div>
{/if}
<MessageInput
bind:chatInputElement
acceptFiles={false}
inputLoading={loading}
showFormattingToolbar={false}
onSubmit={submitHandler}
{onStop}
>
<div slot="menu" class="flex items-center justify-between gap-2 w-full pr-1">
<div>
<Tooltip content={$i18n.t('Edit')} placement="top">
<button
on:click|preventDefault={() => {
editEnabled = !editEnabled;
localStorage.setItem('noteEditEnabled', editEnabled ? 'true' : 'false');
}}
disabled={streaming || loading}
type="button"
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {editEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} disabled:opacity-50 disabled:pointer-events-none"
>
<PencilSquare className="size-4" strokeWidth="1.75" />
<span
class="block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{$i18n.t('Edit')}</span
>
</button>
</Tooltip>
</div>
<Tooltip content={selectedModelId}>
<select
class=" bg-transparent rounded-lg py-1 px-2 -mx-0.5 text-sm outline-hidden w-full text-right pr-5"
bind:value={selectedModelId}
>
{#each $models.filter((model) => !(model?.info?.meta?.hidden ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</Tooltip>
</div>
</MessageInput>
</div>
</div>
</div>
</div>
</div>
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowUpLeft from '$lib/components/icons/ArrowUpLeft.svelte';
export let message;
export let idx;
export let onDelete;
export let onEdit;
export let onInsert;
let textAreaElement: HTMLTextAreaElement;
</script>
<div class="flex flex-col gap-1 group">
<div class="flex items-center justify-between pt-1">
<div class="py-1 text-sm font-normal uppercase min-w-[6rem] text-left rounded-lg transition">
{$i18n.t(message.role)}
</div>
<div class="flex items-center gap-2">
<Tooltip placement="top" content={$i18n.t('Insert')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onInsert();
}}
>
<ArrowUpLeft className="size-3.5" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Edit')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onEdit();
}}
>
<Pencil className="size-3.5" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Delete')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onDelete();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
<div class="flex-1">
<!-- $i18n.t('a user') -->
<!-- $i18n.t('an assistant') -->
{#if !(message?.done ?? true) && message?.content === ''}
<div class="">
<Skeleton size="sm" />
</div>
{:else if message?.edit === true}
<Textarea
className="w-full bg-transparent outline-hidden text-sm resize-none overflow-hidden"
placeholder={$i18n.t(`Enter {{role}} message here`, {
role: message.role === 'user' ? $i18n.t('a user') : $i18n.t('an assistant')
})}
bind:value={message.content}
onBlur={() => {
message.edit = false;
}}
/>
{:else}
<div class=" markdown-prose-sm text-sm">
<Markdown id={`note-message-${idx}`} content={message.content} />
</div>
{/if}
</div>
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import Message from './Message.svelte';
const i18n = getContext('i18n');
export let messages = [];
export let onInsert = (content: string) => {};
</script>
<div class="space-y-3 pb-12">
{#each messages as message, idx}
<Message
{message}
{idx}
onInsert={() => {
onInsert(message?.content ?? '');
}}
onEdit={() => {
messages = messages.map((msg, messageIdx) => {
if (messageIdx === idx) {
return { ...msg, edit: true };
}
return msg;
});
}}
onDelete={() => {
messages = messages.filter((message, messageIdx) => messageIdx !== idx);
}}
/>
{/each}
</div>
@@ -1,103 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
import { models } from '$lib/stores';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import Image from '$lib/components/common/Image.svelte';
export let show = false;
export let selectedModelId = '';
export let files = [];
export let onUpdate = (files: any[]) => {
// Default no-op function
};
</script>
<div class="flex items-center mb-1.5 pt-1.5">
<div class=" mr-1 flex items-center">
<button
class="p-0.5 bg-transparent transition rounded-lg"
on:click={() => {
show = !show;
}}
>
<XMark className="size-5" strokeWidth="2.5" />
</button>
</div>
<div class=" font-normal text-base flex items-center gap-1">
<div>
{$i18n.t('Controls')}
</div>
</div>
</div>
<div class="mt-1 px-1.5">
<div class="pb-10">
{#if files.length > 0}
<div class=" text-xs font-normal mb-2">{$i18n.t('Files')}</div>
<div class="flex flex-col gap-1">
{#each files.filter((file) => file.type !== 'image') as file, fileIdx}
<FileItem
className="w-full"
item={file}
small={true}
edit={true}
dismissible={true}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
loading={file.status === 'uploading'}
on:dismiss={() => {
// Remove the file from the files array
files = files.filter((item) => item.id !== file.id);
files = files;
onUpdate(files);
}}
on:click={() => {
console.log(file);
}}
/>
{/each}
<div class="flex items-center flex-wrap gap-2 mt-1.5">
{#each files.filter((file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) as file, fileIdx}
<Image
src={file.url}
imageClassName=" size-14 rounded-xl object-cover"
dismissible={true}
onDismiss={() => {
files = files.filter((item) => item.id !== file.id);
files = files;
onUpdate(files);
}}
/>
{/each}
</div>
</div>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<div class=" text-xs font-normal mb-1">{$i18n.t('Model')}</div>
<div class="w-full">
<select class="w-full bg-transparent text-sm outline-hidden" bind:value={selectedModelId}>
<option value="" class="bg-gray-50 dark:bg-gray-700" disabled>
{$i18n.t('Select a model')}
</option>
{#each $models.filter((model) => !(model?.info?.meta?.hidden ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
+3 -5
View File
@@ -3,7 +3,6 @@
import { Pane, PaneResizer } from 'paneforge'; import { Pane, PaneResizer } from 'paneforge';
import Drawer from '../common/Drawer.svelte'; import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
export let show = false; export let show = false;
export let pane = null; export let pane = null;
@@ -35,14 +34,13 @@
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
// initialize the minSize based on the container width // initialize the minSize based on the container width
minSize = Math.floor((400 / container.clientWidth) * 100); minSize = Math.floor((350 / container.clientWidth) * 100);
// Create a new ResizeObserver instance // Create a new ResizeObserver instance
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) { for (let entry of entries) {
const width = entry.contentRect.width; const width = entry.contentRect.width;
// calculate the percentage of 200px const percentage = (350 / width) * 100;
const percentage = (400 / width) * 100;
// set the minSize to the percentage, must be an integer // set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage); minSize = Math.floor(percentage);
@@ -99,7 +97,7 @@
{#if show} {#if show}
<div class="flex max-h-full min-h-full"> <div class="flex max-h-full min-h-full">
<div <div
class="w-full pt-2 bg-white dark:shadow-lg dark:bg-gray-850 z-40 pointer-events-auto overflow-y-auto scrollbar-hidden flex flex-col px-2" class="w-full bg-white dark:bg-gray-900 z-40 pointer-events-auto overflow-hidden scrollbar-hidden flex flex-col"
> >
<slot /> <slot />
</div> </div>
@@ -11,6 +11,7 @@
import Link from '$lib/components/icons/Link.svelte'; import Link from '$lib/components/icons/Link.svelte';
import Pin from '$lib/components/icons/Pin.svelte'; import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte'; import PinSlash from '$lib/components/icons/PinSlash.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -21,6 +22,7 @@
export let onDelete = () => {}; export let onDelete = () => {};
export let onPin = null; export let onPin = null;
export let isPinned = false; export let isPinned = false;
export let onChat = null;
export let onCopyLink = null; export let onCopyLink = null;
export let onCopyToClipboard = null; export let onCopyToClipboard = null;
@@ -40,6 +42,19 @@
<div slot="content"> <div slot="content">
<DropdownMenu className="min-w-[180px]"> <DropdownMenu className="min-w-[180px]">
{#if onChat}
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
onChat();
show = false;
}}
>
<ChatBubbleOval className="size-3.5" strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Chat with note')}</div>
</button>
{/if}
<DropdownSub contentClass="select-none z-50"> <DropdownSub contentClass="select-none z-50">
<button <button
slot="trigger" slot="trigger"