mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
refac
This commit is contained in:
parent
b16a4c4e9a
commit
185bca8552
20 changed files with 693 additions and 156 deletions
|
|
@ -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_(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -525,16 +525,18 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" min-w-full markdown-prose {pending ? 'opacity-50' : ''}">
|
||||
<div class="min-w-full {pending ? 'opacity-50' : ''}">
|
||||
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<Markdown
|
||||
id={renderedMessageId}
|
||||
content={message.content}
|
||||
paragraphTag="span"
|
||||
allowEmbeds={!!message?.meta?.model_id}
|
||||
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
<span class="markdown-prose">
|
||||
<Markdown
|
||||
id={renderedMessageId}
|
||||
content={message.content}
|
||||
paragraphTag="span"
|
||||
allowEmbeds={!!message?.meta?.model_id}
|
||||
/>
|
||||
</span>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
|
||||
>{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@
|
|||
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import WebSearchConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Dropdown from '../common/Dropdown.svelte';
|
||||
import DropdownMenu from '../common/DropdownMenu.svelte';
|
||||
import Placeholder from './Placeholder.svelte';
|
||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||
import NotificationToast from '../NotificationToast.svelte';
|
||||
|
|
@ -116,14 +118,25 @@
|
|||
import Sidebar from '../icons/Sidebar.svelte';
|
||||
import Image from '../common/Image.svelte';
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import EditPencilIcon from '../layout/Sidebar/icons/EditPencil.svelte';
|
||||
import ChevronRight from '../icons/ChevronRight.svelte';
|
||||
import EmbeddedChatHistoryItem from './EmbeddedChatHistoryItem.svelte';
|
||||
|
||||
export let chatIdProp = '';
|
||||
export let embedded = false;
|
||||
export let embeddedTitle = '';
|
||||
export let embeddedChats = [];
|
||||
export let embeddedDraftKey = '';
|
||||
export let initialFiles = [];
|
||||
export let selectedText = '';
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
export let onCloseEmbedded: (() => void) | null = null;
|
||||
export let onNewEmbeddedChat: (() => void | Promise<void>) | null = null;
|
||||
export let onCreateEmbeddedChat: (() => any | Promise<any>) | null = null;
|
||||
export let onSelectEmbeddedChat: ((chatId: string) => void | Promise<void>) | null = null;
|
||||
export let onDeleteEmbeddedChat: ((chatId: string) => void | Promise<void>) | null = null;
|
||||
export let onEmbeddedChatTitle: ((chatId: string, title: string) => void | Promise<void>) | null =
|
||||
null;
|
||||
|
||||
let loading = true;
|
||||
$: chatContainerId = embedded ? 'note-chat-container' : 'chat-container';
|
||||
|
|
@ -133,6 +146,9 @@
|
|||
'Extract action items from this note.',
|
||||
'Rewrite the selected text.'
|
||||
];
|
||||
let showEmbeddedChatHistory = false;
|
||||
let embeddedChatOptionsId = '';
|
||||
let deletingEmbeddedChatId = '';
|
||||
|
||||
const eventTarget = new EventTarget();
|
||||
let controlPane: Pane | undefined;
|
||||
|
|
@ -278,10 +294,7 @@
|
|||
};
|
||||
|
||||
$: contextUsage = getContextUsage() ?? serverContextUsage;
|
||||
$: embeddedHeaderTitle =
|
||||
embeddedTitle ||
|
||||
($chatTitle && !$chatTitle.startsWith('Chat:') ? $chatTitle : '') ||
|
||||
$i18n.t('Chat');
|
||||
$: embeddedHeaderTitle = embeddedTitle || $chatTitle || $i18n.t('Chat');
|
||||
|
||||
let selectedToolIds = [];
|
||||
let selectedSkillIds = [];
|
||||
|
|
@ -364,12 +377,12 @@
|
|||
let params = {};
|
||||
let appliedInitialFilesKey = '';
|
||||
let loadedChatIdProp = '';
|
||||
let loadedEmbeddedDraftKey = '';
|
||||
|
||||
const fileKey = (file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
|
||||
const mergeFiles = (current, incoming) => {
|
||||
const seen = new Set();
|
||||
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
|
||||
const key = fileKey(file);
|
||||
const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
|
|
@ -378,7 +391,9 @@
|
|||
const applyInitialFiles = () => {
|
||||
if (!embedded || !initialFiles?.length) return;
|
||||
|
||||
const key = JSON.stringify(initialFiles.map(fileKey));
|
||||
const key = JSON.stringify(
|
||||
initialFiles.map((file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`)
|
||||
);
|
||||
if (key === appliedInitialFilesKey) return;
|
||||
|
||||
files = mergeFiles(files, initialFiles);
|
||||
|
|
@ -389,10 +404,6 @@
|
|||
embedded && selectedText?.trim()
|
||||
? `${text}\n\nSelected note text:\n${selectedText.trim()}`
|
||||
: text;
|
||||
const submitEmbeddedPrompt = async (text: string) => {
|
||||
await tick();
|
||||
await submitHandler(withSelectedText(text));
|
||||
};
|
||||
const noteChatDebug = (message: string, data: Record<string, unknown> = {}) => {
|
||||
if (!embedded) return;
|
||||
console.info('[note-chat]', message, {
|
||||
|
|
@ -415,6 +426,12 @@
|
|||
navigateHandler();
|
||||
}
|
||||
|
||||
$: if (embedded && embeddedDraftKey && embeddedDraftKey !== loadedEmbeddedDraftKey) {
|
||||
noteChatDebug('embedded draft requested', { embeddedDraftKey });
|
||||
loadedEmbeddedDraftKey = embeddedDraftKey;
|
||||
initEmbeddedDraft();
|
||||
}
|
||||
|
||||
let saveControlsTimer;
|
||||
$: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) {
|
||||
clearTimeout(saveControlsTimer);
|
||||
|
|
@ -502,6 +519,46 @@
|
|||
}
|
||||
};
|
||||
|
||||
const initEmbeddedDraft = async () => {
|
||||
clearTimeout(saveControlsTimer);
|
||||
await saveControls();
|
||||
|
||||
if ($chatId && !$temporaryChatEnabled) {
|
||||
updateLastReadAt($chatId);
|
||||
}
|
||||
|
||||
loading = true;
|
||||
loadedChatIdProp = '';
|
||||
chat = null;
|
||||
tags = [];
|
||||
taskIds = null;
|
||||
chatTasks = [];
|
||||
serverContextUsage = null;
|
||||
history = {
|
||||
messages: {},
|
||||
currentId: null
|
||||
};
|
||||
params = {};
|
||||
chatFiles = [];
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
await chatId.set('');
|
||||
await chatTitle.set('');
|
||||
|
||||
await setDefaults();
|
||||
loading = false;
|
||||
await tick();
|
||||
applyInitialFiles();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
};
|
||||
|
||||
const onSelect = async (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
|
|
@ -888,6 +945,9 @@
|
|||
message.favorite = data.favorite;
|
||||
} else if (type === 'chat:title') {
|
||||
chatTitle.set(data);
|
||||
if (embedded && $chatId) {
|
||||
await onEmbeddedChatTitle?.($chatId, data);
|
||||
}
|
||||
await refreshChatList(localStorage.token);
|
||||
} else if (type === 'chat:tags') {
|
||||
chat = await getChatById(localStorage.token, $chatId);
|
||||
|
|
@ -2655,9 +2715,26 @@
|
|||
}
|
||||
history = history;
|
||||
|
||||
// New chat — backend generates the chat_id on first request
|
||||
// Empty embedded drafts create their backing chat only when the first message is sent.
|
||||
if (!_chatId) {
|
||||
if ($temporaryChatEnabled) {
|
||||
if (embedded && onCreateEmbeddedChat) {
|
||||
const createdChat = await onCreateEmbeddedChat();
|
||||
if (!createdChat?.id) {
|
||||
toast.error($i18n.t('Failed to create chat'));
|
||||
return;
|
||||
}
|
||||
|
||||
chat = createdChat;
|
||||
_chatId = createdChat.id;
|
||||
loadedChatIdProp = _chatId;
|
||||
await chatId.set(_chatId);
|
||||
await chatTitle.set(createdChat?.chat?.title ?? createdChat?.title ?? $i18n.t('Chat'));
|
||||
|
||||
params = structuredClone(createdChat?.chat?.params ?? {});
|
||||
delete params.note_id;
|
||||
chatFiles = mergeFiles(chatFiles, createdChat?.chat?.files ?? []);
|
||||
applyInitialFiles();
|
||||
} else if ($temporaryChatEnabled) {
|
||||
_chatId = `local:${$socket?.id}`;
|
||||
await chatId.set(_chatId);
|
||||
}
|
||||
|
|
@ -2954,7 +3031,11 @@
|
|||
...(continueResponse ? { assistant_message_id: responseMessageId } : {}),
|
||||
|
||||
background_tasks: {
|
||||
...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null
|
||||
...(!$temporaryChatEnabled &&
|
||||
(!_chatId ||
|
||||
(embedded &&
|
||||
(userMessage?.parentId ?? null) === null &&
|
||||
createMessagesList(_history, responseMessageId).length === 2))
|
||||
? {
|
||||
title_generation: $settings?.title?.auto ?? true,
|
||||
tags_generation: $settings?.autoTags ?? true
|
||||
|
|
@ -3540,13 +3621,88 @@
|
|||
<div
|
||||
class="h-10 shrink-0 flex items-center justify-between gap-2 border-b border-gray-50/80 px-3 text-gray-700 dark:border-gray-850/40 dark:text-gray-200"
|
||||
>
|
||||
<div class="min-w-0 truncate text-[13px] font-medium">
|
||||
{embeddedHeaderTitle}
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Dropdown
|
||||
bind:show={showEmbeddedChatHistory}
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
closeOnOutsideClick={embeddedChatOptionsId === ''}
|
||||
onOpenChange={(state) => {
|
||||
if (!state) embeddedChatOptionsId = '';
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 items-center gap-1 text-[13px] font-normal text-gray-600 transition hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||
aria-label={$i18n.t('Chat history')}
|
||||
>
|
||||
<span class="min-w-0 truncate">{embeddedHeaderTitle}</span>
|
||||
<ChevronRight
|
||||
className="size-3.5 shrink-0 text-gray-400/70 opacity-0 transition-opacity group-hover:opacity-100 dark:text-gray-500/70"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu
|
||||
className="min-w-56 max-w-72 max-h-80 overflow-y-auto scrollbar-hidden"
|
||||
>
|
||||
{#if onNewEmbeddedChat && Object.keys(history?.messages ?? {}).length > 0 && !loading}
|
||||
<button
|
||||
type="button"
|
||||
class="text-left"
|
||||
on:click={async () => {
|
||||
showEmbeddedChatHistory = false;
|
||||
await onNewEmbeddedChat?.();
|
||||
}}
|
||||
>
|
||||
<EditPencilIcon className="size-3.5" strokeWidth="1.5" />
|
||||
<span class="min-w-0 truncate">{$i18n.t('New chat')}</span>
|
||||
</button>
|
||||
<hr class="border-gray-100/70 dark:border-gray-800/60" />
|
||||
{/if}
|
||||
{#if embeddedChats.length > 0}
|
||||
{#each embeddedChats as item}
|
||||
<EmbeddedChatHistoryItem
|
||||
{item}
|
||||
title={item?.id === $chatId
|
||||
? embeddedHeaderTitle
|
||||
: item?.title || item?.chat?.title || $i18n.t('Chat')}
|
||||
selected={item.id === $chatId}
|
||||
deleting={deletingEmbeddedChatId === item.id}
|
||||
onSelect={async () => {
|
||||
showEmbeddedChatHistory = false;
|
||||
await onSelectEmbeddedChat?.(item.id);
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
if (!id || deletingEmbeddedChatId) return;
|
||||
|
||||
deletingEmbeddedChatId = id;
|
||||
embeddedChatOptionsId = '';
|
||||
try {
|
||||
await onDeleteEmbeddedChat?.(id);
|
||||
} finally {
|
||||
deletingEmbeddedChatId = '';
|
||||
}
|
||||
}}
|
||||
onMenuOpenChange={(id, state) => {
|
||||
embeddedChatOptionsId = state ? id : '';
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-2 py-1.5 text-[13px] text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('No chat history')}
|
||||
</div>
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Tooltip content={$i18n.t('Close')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-gray-500 transition hover:bg-black/5 hover:text-gray-900 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
class="rounded-md p-1 text-gray-500 transition hover:text-gray-900 dark:hover:text-white"
|
||||
on:click={() => onCloseEmbedded?.()}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
|
|
@ -3610,13 +3766,12 @@
|
|||
toast.success($i18n.t('Conversation saved successfully'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving conversation:', error);
|
||||
console.error('Failed to save temporary chat:', error);
|
||||
toast.error($i18n.t('Failed to save conversation'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div id="chat-pane" class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
|
||||
{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
|
||||
<div
|
||||
|
|
@ -3768,7 +3923,10 @@
|
|||
<button
|
||||
type="button"
|
||||
class="flex min-h-8 w-full items-center justify-between py-1 text-left text-[13px] leading-5 text-gray-500 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
on:click={() => submitEmbeddedPrompt(suggestion)}
|
||||
on:click={async () => {
|
||||
await tick();
|
||||
await submitHandler(withSelectedText(suggestion));
|
||||
}}
|
||||
>
|
||||
<span class="min-w-0 truncate">{$i18n.t(suggestion)}</span>
|
||||
</button>
|
||||
|
|
|
|||
88
src/lib/components/chat/EmbeddedChatHistoryItem.svelte
Normal file
88
src/lib/components/chat/EmbeddedChatHistoryItem.svelte
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let item: any;
|
||||
export let title = '';
|
||||
export let selected = false;
|
||||
export let deleting = false;
|
||||
|
||||
export let onSelect: (id: string) => void | Promise<void> = () => {};
|
||||
export let onDelete: (id: string) => void | Promise<void> = () => {};
|
||||
export let onMenuOpenChange: (id: string, state: boolean) => void = () => {};
|
||||
|
||||
let showMenu = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group/item flex h-8 w-full cursor-pointer select-none items-center rounded-xl px-2 text-left text-[13px] font-normal text-gray-700 outline-hidden transition-colors duration-75 hover:bg-gray-50/40 dark:text-gray-100 dark:hover:bg-gray-800/40 {selected
|
||||
? 'bg-gray-50/70 dark:bg-gray-800/60'
|
||||
: ''}"
|
||||
on:click={() => onSelect(item.id)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div class="min-w-0 truncate">{title}</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('Current')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1.5 pl-2">
|
||||
<Dropdown
|
||||
bind:show={showMenu}
|
||||
align="end"
|
||||
sideOffset={-2}
|
||||
onOpenChange={(state) => {
|
||||
showMenu = state;
|
||||
onMenuOpenChange(item.id, state);
|
||||
}}
|
||||
>
|
||||
<Tooltip content={$i18n.t('More')} className="group-hover/item:opacity-100 opacity-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('More Options')}
|
||||
class="flex"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
onMenuOpenChange(item.id, showMenu);
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontal className="size-4" strokeWidth="1.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[160px] z-[9999999]">
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
|
||||
disabled={deleting}
|
||||
on:click={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showMenu = false;
|
||||
onMenuOpenChange(item.id, false);
|
||||
await onDelete(item.id);
|
||||
}}
|
||||
>
|
||||
<GarbageBin className="size-3.5" strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -283,31 +283,35 @@
|
|||
onPreview={previewHandler}
|
||||
/>
|
||||
{:else if $settings?.renderMarkdownInAssistantMessages ?? true}
|
||||
<Markdown
|
||||
{id}
|
||||
content={formatMessageContent(content)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={markdownUpdateHandler}
|
||||
onPreview={previewHandler}
|
||||
/>
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
{id}
|
||||
content={formatMessageContent(content)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={markdownUpdateHandler}
|
||||
onPreview={previewHandler}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{@const extracted = extractDetailsBlocks(content)}
|
||||
|
||||
{#if extracted.detailsContent}
|
||||
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
|
||||
<Markdown {id} content={extracted.detailsContent} {done} />
|
||||
<div class="markdown-prose">
|
||||
<Markdown {id} content={extracted.detailsContent} {done} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if extracted.plainContent}
|
||||
<div class="whitespace-pre-wrap">{extracted.plainContent}</div>
|
||||
<div class="whitespace-pre-wrap text-sm">{extracted.plainContent}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="my-2 flex w-full items-start gap-3 rounded-3xl bg-black/[0.03] px-4 py-3 text-gray-500 dark:bg-white/[0.04] dark:text-gray-400"
|
||||
class="my-1.5 flex w-full items-start gap-2 rounded-2xl bg-black/[0.03] px-3 py-2 text-gray-500 dark:bg-white/[0.04] dark:text-gray-400"
|
||||
>
|
||||
<Info className="mt-0.5 size-5 shrink-0 text-gray-400 dark:text-gray-500" strokeWidth="2" />
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-gray-400 dark:text-gray-500" strokeWidth="1.8" />
|
||||
|
||||
<div class="min-w-0 break-words text-sm leading-6">
|
||||
<div class="min-w-0 break-words text-[0.8125rem] leading-5">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
import citationExtension from '$lib/utils/marked/citation-extension';
|
||||
|
||||
const options = {
|
||||
throwOnError: false,
|
||||
breaks: true
|
||||
throwOnError: false
|
||||
};
|
||||
|
||||
marked.use(markedKatexExtension(options));
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
<div {id} class="w-full">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<button
|
||||
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
class="w-fit py-1 text-left text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
|
|
@ -158,7 +158,7 @@
|
|||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
<div class="mb-0.5 space-y-0.5">
|
||||
<div class="mb-1 space-y-1.5">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
};
|
||||
|
||||
$: displayTokens = getDisplayTokens(tokens);
|
||||
$: singlePlainBlock =
|
||||
displayTokens.length === 1 &&
|
||||
(displayTokens[0]?.type === 'paragraph' || displayTokens[0]?.type === 'text');
|
||||
|
||||
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
|
||||
console.log('Exporting table to CSV');
|
||||
|
|
@ -141,7 +144,7 @@
|
|||
<!-- {JSON.stringify(tokens)} -->
|
||||
{#each displayTokens as token, tokenIdx (tokenIdx)}
|
||||
{#if token.type === 'hr'}
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
<hr class="border-gray-50 dark:border-gray-850/30" />
|
||||
{:else if token.type === 'heading'}
|
||||
<svelte:element this={headerComponent(token.depth)} dir="auto">
|
||||
<MarkdownInlineTokens
|
||||
|
|
@ -385,7 +388,7 @@
|
|||
resultContent={getDetailTextContent(detailToken)}
|
||||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -393,7 +396,7 @@
|
|||
open={$settings?.expandDetails ?? false}
|
||||
attributes={detailToken?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
dir="auto"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
|
|
@ -416,7 +419,7 @@
|
|||
disabled={true}
|
||||
attributes={detailToken?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
dir="auto"
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -433,7 +436,7 @@
|
|||
attributes={token.attributes}
|
||||
resultContent={getDetailTextContent(token)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -441,7 +444,7 @@
|
|||
open={$settings?.expandDetails ?? false}
|
||||
attributes={token?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
dir="auto"
|
||||
>
|
||||
<div class=" mb-1.5" slot="content">
|
||||
|
|
@ -464,7 +467,7 @@
|
|||
disabled={true}
|
||||
attributes={token?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
dir="auto"
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -495,7 +498,7 @@
|
|||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<p dir="auto">
|
||||
<p dir="auto" class={singlePlainBlock ? '!my-0' : ''}>
|
||||
<MarkdownInlineTokens
|
||||
id={`${id}-${tokenIdx}-p`}
|
||||
tokens={token.tokens ?? []}
|
||||
|
|
@ -507,7 +510,7 @@
|
|||
{/if}
|
||||
{:else if token.type === 'text'}
|
||||
{#if top}
|
||||
<p>
|
||||
<p class={singlePlainBlock ? '!my-0' : ''}>
|
||||
{#if token.tokens}
|
||||
<MarkdownInlineTokens
|
||||
id={`${id}-${tokenIdx}-t`}
|
||||
|
|
@ -551,7 +554,7 @@
|
|||
{onSourceClick}
|
||||
/>
|
||||
{:else if token.type === 'space'}
|
||||
<div class="my-2" />
|
||||
<!-- skip -->
|
||||
{:else}
|
||||
{console.log('Unknown token', token)}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -405,11 +405,13 @@
|
|||
{$i18n.t('Merged Response')}
|
||||
</Name>
|
||||
|
||||
<div class="mt-1 markdown-prose w-full min-w-full">
|
||||
<div class="mt-1 w-full min-w-full">
|
||||
{#if (message?.content ?? '') === ''}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<Markdown id={`merged`} content={message.content ?? ''} />
|
||||
<div class="markdown-prose">
|
||||
<Markdown id={`merged`} content={message.content ?? ''} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<div class=" self-center font-normal line-clamp-1 flex gap-1 items-center">
|
||||
<div class=" self-center text-sm font-normal line-clamp-1 flex gap-1 items-center">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -655,10 +655,10 @@
|
|||
dir={$settings.chatDirection}
|
||||
style="scroll-margin-top: 3rem;"
|
||||
>
|
||||
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 hidden @lg:flex mt-1 `}>
|
||||
<div class={`shrink-0 ltr:mr-2 rtl:ml-2 hidden @lg:flex mt-0.5 `}>
|
||||
<ProfileImage
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model?.id}&lang=${$i18n.language}`}
|
||||
className={'size-8 assistant-message-profile-image'}
|
||||
className={'size-7 assistant-message-profile-image'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -672,7 +672,7 @@
|
|||
</Name>
|
||||
|
||||
<div>
|
||||
<div class="chat-{message.role} w-full min-w-full markdown-prose">
|
||||
<div class="chat-{message.role} w-full min-w-full">
|
||||
<div>
|
||||
{#if model?.info?.meta?.capabilities?.status_updates ?? true}
|
||||
<StatusHistory statusHistory={message?.statusHistory} />
|
||||
|
|
|
|||
|
|
@ -38,24 +38,26 @@
|
|||
{#each displayItems as displayItem (displayItem.id)}
|
||||
{#if displayItem.type === 'message'}
|
||||
{#if renderMarkdown}
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}`}
|
||||
content={formatMessageContent(displayItem.text)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
{onUpdate}
|
||||
{onPreview}
|
||||
/>
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}`}
|
||||
content={formatMessageContent(displayItem.text)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
{onUpdate}
|
||||
{onPreview}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="whitespace-pre-wrap">{displayItem.text}</div>
|
||||
<div class="whitespace-pre-wrap text-sm">{displayItem.text}</div>
|
||||
{/if}
|
||||
{:else if displayItem.type === 'detail_group'}
|
||||
<ConsecutiveDetailsGroup
|
||||
|
|
@ -63,7 +65,7 @@
|
|||
tokens={displayItem.tokens}
|
||||
messageDone={done}
|
||||
>
|
||||
<div slot="content" class="space-y-1">
|
||||
<div slot="content" class="space-y-2">
|
||||
{#each displayItem.tokens as detailToken, detailIndex}
|
||||
{#if detailToken.attributes?.type === 'tool_calls'}
|
||||
<ToolCallDisplay
|
||||
|
|
@ -72,7 +74,7 @@
|
|||
resultContent={detailToken.text}
|
||||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -80,15 +82,17 @@
|
|||
open={$settings?.expandDetails ?? false}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else}
|
||||
|
|
@ -98,7 +102,7 @@
|
|||
disabled={true}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
@ -112,7 +116,7 @@
|
|||
attributes={detailToken.attributes}
|
||||
resultContent={detailToken.text}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -120,15 +124,17 @@
|
|||
open={$settings?.expandDetails ?? false}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else}
|
||||
|
|
@ -138,7 +144,7 @@
|
|||
disabled={true}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
className="w-full space-y-2"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -144,12 +144,12 @@
|
|||
style="scroll-margin-top: 3rem;"
|
||||
>
|
||||
{#if !($settings?.chatBubble ?? true) && !subagentResult}
|
||||
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
|
||||
<div class={`shrink-0 ltr:mr-2 rtl:ml-2 mt-0.5`}>
|
||||
<ProfileImage
|
||||
src={user?.id
|
||||
? `${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
className={'size-8 user-message-profile-image'}
|
||||
className={'size-7 user-message-profile-image'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -169,7 +169,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="chat-{message.role} w-full min-w-full markdown-prose">
|
||||
<div class="chat-{message.role} w-full min-w-full">
|
||||
{#if edit !== true}
|
||||
{#if message.files}
|
||||
<div
|
||||
|
|
@ -271,7 +271,7 @@
|
|||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
bind:this={messageEditTextAreaElement}
|
||||
class=" bg-transparent outline-hidden w-full resize-none"
|
||||
class=" bg-transparent outline-hidden w-full resize-none text-sm"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
|
|
@ -349,14 +349,16 @@
|
|||
>
|
||||
{#if message.content}
|
||||
{#if $settings?.renderMarkdownInUserMessages ?? true}
|
||||
<Markdown
|
||||
id={`${chatId}-${message.id}`}
|
||||
content={message.content}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
/>
|
||||
<div class="markdown-prose">
|
||||
<Markdown
|
||||
id={`${chatId}-${message.id}`}
|
||||
content={message.content}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="whitespace-pre-wrap" dir={$settings?.chatDirection ?? 'auto'}>
|
||||
<div class="whitespace-pre-wrap text-sm" dir={$settings?.chatDirection ?? 'auto'}>
|
||||
{message.content}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
export let className = '';
|
||||
export let buttonClassName =
|
||||
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
'w-fit py-1 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
|
||||
export let id = '';
|
||||
export let title = null;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
$: if (!open) expandedResult = false;
|
||||
export let buttonClassName =
|
||||
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
'w-fit py-1 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
|
||||
const componentId = id || uuidv4();
|
||||
|
||||
|
|
|
|||
|
|
@ -63,13 +63,16 @@
|
|||
|
||||
import {
|
||||
deleteNoteById,
|
||||
createNoteChatById,
|
||||
getNoteById,
|
||||
getNoteChatById,
|
||||
getNoteChatsById,
|
||||
updateNoteById,
|
||||
updateNoteAccessGrants,
|
||||
toggleNotePinnedStatusById,
|
||||
getPinnedNoteList
|
||||
} from '$lib/apis/notes';
|
||||
import { deleteChatById } from '$lib/apis/chats';
|
||||
|
||||
import RichTextInput from '../common/RichTextInput.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
|
|
@ -134,8 +137,23 @@
|
|||
let showNoteChat = false;
|
||||
let noteChatId = null;
|
||||
let noteChatLoading = false;
|
||||
let noteChats = [];
|
||||
let noteChatDraftKey = '';
|
||||
let noteChatCreating = false;
|
||||
|
||||
let selectedContent = null;
|
||||
let noteChatFiles = [];
|
||||
$: {
|
||||
const seen = new Set();
|
||||
noteChatFiles = note
|
||||
? (note?.data?.files ?? files ?? []).filter((file) => {
|
||||
const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
let showDeleteConfirm = false;
|
||||
let showAccessControlModal = false;
|
||||
|
|
@ -540,20 +558,6 @@ ${content}
|
|||
});
|
||||
};
|
||||
|
||||
const noteChatFileKey = (file) =>
|
||||
`${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
|
||||
const getNoteChatFiles = () => {
|
||||
if (!note) return [];
|
||||
|
||||
const seen = new Set();
|
||||
return (note?.data?.files ?? files ?? []).filter((file) => {
|
||||
const key = noteChatFileKey(file);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const openNoteChat = async () => {
|
||||
console.info('[note-chat] open requested', {
|
||||
noteId: note?.id,
|
||||
|
|
@ -569,6 +573,12 @@ ${content}
|
|||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
const chats = chat
|
||||
? await getNoteChatsById(localStorage.token, note.id).catch((error) => {
|
||||
console.error('[note-chat] history failed', { noteId: note?.id, error });
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
noteChatLoading = false;
|
||||
|
||||
if (chat?.id) {
|
||||
|
|
@ -579,12 +589,84 @@ ${content}
|
|||
hasChatPayload: !!chat.chat
|
||||
});
|
||||
noteChatId = chat.id;
|
||||
noteChats = chats ?? [chat];
|
||||
showNoteChat = true;
|
||||
} else {
|
||||
console.warn('[note-chat] open returned no chat id', { noteId: note.id, chat });
|
||||
}
|
||||
};
|
||||
|
||||
const createNoteChat = async () => {
|
||||
if (!note?.id || noteChatLoading) return;
|
||||
|
||||
noteChatId = null;
|
||||
noteChatDraftKey = `${Date.now()}`;
|
||||
showNoteChat = true;
|
||||
};
|
||||
|
||||
const createNoteChatOnFirstMessage = async () => {
|
||||
if (!note?.id || noteChatCreating) return null;
|
||||
|
||||
noteChatCreating = true;
|
||||
try {
|
||||
const chat = await createNoteChatById(localStorage.token, note.id).catch((error) => {
|
||||
console.error('[note-chat] create failed', { noteId: note?.id, error });
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
const chats = chat
|
||||
? await getNoteChatsById(localStorage.token, note.id).catch((error) => {
|
||||
console.error('[note-chat] history failed', { noteId: note?.id, error });
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (chat?.id) {
|
||||
noteChatId = chat.id;
|
||||
noteChatDraftKey = '';
|
||||
noteChats = chats ?? [chat, ...noteChats.filter((item) => item.id !== chat.id)];
|
||||
showNoteChat = true;
|
||||
}
|
||||
|
||||
return chat;
|
||||
} finally {
|
||||
noteChatCreating = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNoteChat = async (chatId) => {
|
||||
if (!note?.id || !chatId) return;
|
||||
|
||||
const deleted = await deleteChatById(localStorage.token, chatId).catch((error) => {
|
||||
console.error('[note-chat] delete failed', { noteId: note?.id, chatId, error });
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!deleted) return;
|
||||
|
||||
let chats =
|
||||
(await getNoteChatsById(localStorage.token, note.id).catch((error) => {
|
||||
console.error('[note-chat] history failed', { noteId: note?.id, error });
|
||||
return null;
|
||||
})) ?? [];
|
||||
|
||||
if (noteChatId === chatId) {
|
||||
let nextChat = chats[0];
|
||||
if (!nextChat) {
|
||||
nextChat = await getNoteChatById(localStorage.token, note.id).catch((error) => {
|
||||
console.error('[note-chat] recreate failed after delete', { noteId: note?.id, error });
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
chats = nextChat ? [nextChat] : [];
|
||||
}
|
||||
noteChatId = nextChat?.id ?? null;
|
||||
}
|
||||
|
||||
noteChats = chats;
|
||||
};
|
||||
|
||||
const downloadHandler = async (type) => {
|
||||
console.log('downloadHandler', type);
|
||||
if (type === 'txt') {
|
||||
|
|
@ -1241,13 +1323,36 @@ ${content}
|
|||
<div class="flex h-full items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else if noteChatId}
|
||||
{:else if noteChatId || noteChatDraftKey}
|
||||
<Chat
|
||||
embedded={true}
|
||||
chatIdProp={noteChatId}
|
||||
initialFiles={getNoteChatFiles()}
|
||||
chatIdProp={noteChatId ?? ''}
|
||||
embeddedChats={noteChats}
|
||||
embeddedDraftKey={noteChatDraftKey}
|
||||
initialFiles={noteChatFiles}
|
||||
selectedText={selectedContent?.text ?? ''}
|
||||
onInsertToNote={insertHandler}
|
||||
onNewEmbeddedChat={createNoteChat}
|
||||
onCreateEmbeddedChat={createNoteChatOnFirstMessage}
|
||||
onSelectEmbeddedChat={(chatId) => {
|
||||
if (!chatId || chatId === noteChatId) return;
|
||||
noteChatId = chatId;
|
||||
}}
|
||||
onDeleteEmbeddedChat={deleteNoteChat}
|
||||
onEmbeddedChatTitle={(chatId, title) => {
|
||||
noteChats = noteChats.map((chat) =>
|
||||
chat.id === chatId
|
||||
? {
|
||||
...chat,
|
||||
title,
|
||||
chat: {
|
||||
...(chat.chat ?? {}),
|
||||
title
|
||||
}
|
||||
}
|
||||
: chat
|
||||
);
|
||||
}}
|
||||
onCloseEmbedded={() => {
|
||||
showNoteChat = false;
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue