Files
open-webui/src/lib/components/chat/Chat.svelte
Timothy Jaeryang Baek d864669b59 refac
2026-07-15 23:38:48 -04:00

3992 lines
110 KiB
Svelte

<script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { fade } from 'svelte/transition';
const i18n: Writable<i18nType> = getContext('i18n');
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get, type Unsubscriber, type Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
import {
chatId,
config,
type Model,
models,
tags as allTags,
settings,
showSidebar,
WEBUI_NAME,
banners,
user,
socket,
audioQueue,
showControls,
showCallOverlay,
temporaryChatEnabled,
mobile,
chatTitle,
showArtifacts,
artifactContents,
tools,
skills,
toolServers,
terminalServers,
functions,
selectedFolder,
showEmbeds,
selectedTerminalId,
showFileNavPath,
showFileNavDir,
chatRequestQueues,
desktopEvent
} from '$lib/stores';
import { refreshChatList } from '$lib/stores/chat-list';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
convertMessagesToHistory,
copyToClipboard,
getMessageContentParts,
createMessagesList,
sanitizeHistory,
getPromptVariables,
processDetails,
removeAllDetails,
getCodeBlockContents,
isYoutubeUrl,
displayFileHandler
} from '$lib/utils';
import { AudioQueue } from '$lib/utils/audio';
import { getOutputText } from './Messages/structuredOutput';
import {
archiveChatById,
cloneChatById,
compactChatById,
createNewChat,
deleteChatById,
getAllTags,
getChatById,
getTagsById,
updateChatById,
updateChatFolderIdById
} from '$lib/apis/chats';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import {
generateQueries,
chatAction,
generateMoACompletion,
stopTask,
stopTasksByChatId,
getTaskIdsByChatId
} from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { getSkills } from '$lib/apis/skills';
import { uploadFile } from '$lib/apis/files';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { getFunctions } from '$lib/apis/functions';
import { initiateOAuthRedirect } from '$lib/apis/configs';
import { updateFolderById } from '$lib/apis/folders';
import Banner from '../common/Banner.svelte';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
import Messages from '$lib/components/chat/Messages.svelte';
import Navbar from '$lib/components/chat/Navbar.svelte';
import ChatControls from './ChatControls.svelte';
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
import WebSearchConfirmDialog from '../common/ConfirmDialog.svelte';
import Placeholder from './Placeholder.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte';
import { isEmbedWindow } from '../common/FullHeightIframe.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte';
import EmbeddedChatHistoryDropdown from './EmbeddedChatHistoryDropdown.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';
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();
let controlPane: Pane | undefined;
let controlPaneComponent: ChatControls | undefined;
let messageInput: MessageInput | undefined;
let messagesRef: Messages | undefined;
let autoScroll = true;
let isNearTop = true;
let processing = '';
let messagesContainerElement: HTMLDivElement;
let navbarElement;
let showEventConfirmation = false;
let eventConfirmationTitle = '';
let eventConfirmationMessage = '';
let eventConfirmationInput = false;
let eventConfirmationInputPlaceholder = '';
let eventConfirmationInputValue = '';
let eventConfirmationInputType = '';
let eventConfirmationInputOptions: ({ label?: string; value: string } | string)[] = [];
let eventCallback = null;
let selectedModels = [''];
let atSelectedModel: Model | undefined;
let selectedModelIds = [];
$: if (atSelectedModel !== undefined) {
selectedModelIds = [atSelectedModel.id];
} else {
selectedModelIds = selectedModels;
}
let serverContextUsage = 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) => {
if (value === null || value === undefined || value === '') {
return 0;
}
if (typeof value !== 'string') {
try {
value = JSON.stringify(value);
} catch {
value = String(value);
}
}
return Math.max(1, Math.floor(value.length / 4));
};
const estimateMessagesTokens = (messages) =>
messages.reduce((total, message) => {
let next = total + 4 + estimateTokens(message.content);
next += estimateTokens(message.output);
next += estimateTokens(message.tool_calls);
next += estimateTokens(message.files);
return next;
}, 0);
const getContextThreshold = () => {
const chatThreshold = Number(params?.compact_token_threshold);
if (Number.isFinite(chatThreshold) && chatThreshold > 0) {
return chatThreshold;
}
const modelId = atSelectedModel?.id ?? selectedModels.find((id) => id);
const model = $models.find((item) => item.id === modelId);
const threshold = Number(model?.info?.params?.compact_token_threshold);
return Number.isFinite(threshold) && threshold > 0 ? threshold : 80000;
};
const getContextUsage = () => {
if (!history?.currentId) {
return null;
}
const messages = createMessagesList(history, history.currentId);
const threshold = getContextThreshold();
const systemTokens = estimateTokens($settings?.system ?? '');
let estimatedTokens = systemTokens;
let hasUsageCheckpoint = false;
let summary = '';
let startIdx = 0;
for (let idx = 0; idx < messages.length; idx += 1) {
const value = messages[idx]?.contextSummary ?? messages[idx]?.context_summary;
if (typeof value === 'string' && value.trim()) {
summary = value;
startIdx = idx;
}
}
const activeMessages = messages.slice(startIdx);
for (let idx = activeMessages.length - 1; idx >= 0; idx -= 1) {
const usage = activeMessages[idx]?.usage ?? activeMessages[idx]?.info?.usage;
const inputTokens = usage?.input_tokens ?? usage?.prompt_tokens;
if (inputTokens) {
hasUsageCheckpoint = true;
estimatedTokens =
Number(inputTokens || 0) +
Number(usage.output_tokens ?? usage.completion_tokens ?? 0) +
estimateMessagesTokens(activeMessages.slice(idx + 1));
break;
}
}
if (!hasUsageCheckpoint) {
estimatedTokens += estimateTokens(summary) + estimateMessagesTokens(activeMessages);
}
return {
tokens: estimatedTokens,
estimated_tokens: estimatedTokens,
threshold,
percent: threshold > 0 ? Math.max(0, Math.round((estimatedTokens / threshold) * 100)) : 0,
source: 'estimated'
};
};
$: contextUsage = getContextUsage() ?? serverContextUsage;
$: embeddedHeaderTitle = embeddedTitle || $chatTitle || $i18n.t('Chat');
let selectedToolIds = [];
let selectedSkillIds = [];
let selectedFilterIds = [];
let pendingOAuthTools = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
let webSearchActive = false;
let showWebSearchConfirm = false;
let pendingWebSearchPrompt: string | null = null;
let webSearchConfirmed = false;
$: {
const currentModels = atSelectedModel?.id ? [atSelectedModel.id] : selectedModels;
const allModelsSupportWebSearch =
currentModels.filter(
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
).length === currentModels.length;
webSearchActive = Boolean(
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search) &&
(webSearchEnabled ||
(allModelsSupportWebSearch && ($settings?.webSearch ?? false) === 'always'))
);
}
const openWebSearchConfirm = () => {
window.setTimeout(() => {
showWebSearchConfirm = true;
}, 0);
};
const handleWebSearchToggle = (enabled: boolean) => {
if (enabled && $config?.features?.enable_web_search_confirmation && !webSearchConfirmed) {
webSearchEnabled = false;
pendingWebSearchPrompt = null;
openWebSearchConfirm();
}
};
const resetWebSearchConfirmation = () => {
webSearchConfirmed = false;
pendingWebSearchPrompt = null;
showWebSearchConfirm = false;
};
$: if (!webSearchActive) {
resetWebSearchConfirmation();
}
let showCommands = false;
let generating = false;
let dragged = false;
let generationController = null;
let contextCompactionToastId = null;
let chat = null;
let tags = [];
// Read-only when viewing someone else's chat (e.g. via shared folder access)
$: readOnly = chat != null && chat.user_id !== $user?.id;
let chatTasks = [];
let history = {
messages: {},
currentId: null
};
let taskIds = null;
// Chat Input
let prompt = '';
let chatFiles = [];
let files = [];
let params = {};
let attachedFilesKey = '';
let loadedChatIdProp = '';
let currentDraftKey = '';
const mergeFiles = (current, incoming) => {
const seen = new Set();
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const applyInitialFiles = () => {
if (!embedded || !initialFiles?.length) return;
const key = JSON.stringify(
initialFiles.map((file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`)
);
if (key === attachedFilesKey) return;
files = mergeFiles(files, initialFiles);
chatFiles = mergeFiles(chatFiles, initialFiles);
attachedFilesKey = key;
};
const withSelectedText = (text: string) =>
embedded && selectedText?.trim()
? `${text}\n\nSelected note text for replace_note_content operations:\n${selectedText.trim()}`
: 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();
}
$: if (embedded && embeddedDraftKey && embeddedDraftKey !== currentDraftKey) {
noteChatDebug('embedded draft requested', { embeddedDraftKey });
currentDraftKey = embeddedDraftKey;
initEmbeddedDraft();
}
let saveControlsTimer;
$: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) {
clearTimeout(saveControlsTimer);
saveControlsTimer = setTimeout(saveControls, 400);
}
const navigateHandler = async () => {
noteChatDebug('navigateHandler start');
// Mark the outgoing chat as read before loading the new one.
// $chatId still holds the previous chat here — loadChat() updates it.
if ($chatId && $chatId !== chatIdProp && !$temporaryChatEnabled) {
noteChatDebug('marking outgoing chat read', { outgoingChatId: $chatId });
updateLastReadAt($chatId);
}
clearTimeout(saveControlsTimer);
await saveControls();
loading = true;
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
const loaded = chatIdProp ? await loadChat() : false;
noteChatDebug('loadChat completed inside navigateHandler', { loaded });
if (loaded) {
await tick();
loading = false;
noteChatDebug('embedded chat loading false');
window.setTimeout(() => scrollToBottom(), 0);
await tick();
// Mark chat read when initially loading it
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
}
// Process any queued requests if the chat is idle
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
const isIdle = !lastMessage || lastMessage.role !== 'assistant' || lastMessage.done;
if (isIdle) {
await processNextInQueue(chatIdProp);
}
if (storageChatInput) {
try {
const input = JSON.parse(storageChatInput);
if (!$temporaryChatEnabled) {
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedSkillIds = input.selectedSkillIds ?? [];
selectedFilterIds = input.selectedFilterIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
}
} catch (e) {}
} else {
await setDefaults();
}
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
} else if (!embedded) {
await goto('/');
} else {
loading = false;
console.warn('[note-chat] embedded load failed; clearing spinner', {
chatIdProp,
activeChatId: $chatId
});
}
};
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;
if (type === 'prompt') {
// Handle prompt selection
messageInput?.setText(data, async () => {
if (!($settings?.insertSuggestionPrompt ?? false)) {
await tick();
submitHandler(prompt);
}
});
}
};
$: if (selectedModels && chatIdProp !== '') {
saveSessionSelectedModels();
}
const saveSessionSelectedModels = () => {
const selectedModelsString = JSON.stringify(selectedModels);
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '') ||
sessionStorage.selectedModels === selectedModelsString
) {
return;
}
sessionStorage.selectedModels = selectedModelsString;
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
};
const continueOAuthRedirect = async () => {
if (pendingOAuthTools.length === 0) {
sessionStorage.removeItem('oauthRedirectInProgressToolId');
return;
}
if (chatIdProp) {
return;
}
const nextTool = pendingOAuthTools[0];
if (sessionStorage.getItem('oauthRedirectInProgressToolId') === nextTool.id) {
sessionStorage.removeItem('oauthRedirectInProgressToolId');
return;
}
saveSessionSelectedModels();
await tick();
initiateOAuthRedirect(nextTool);
};
let oldSelectedModelIds = [''];
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
}
const onSelectedModelIdsChange = () => {
resetInput();
oldSelectedModelIds = structuredClone(selectedModelIds);
};
const resetInput = async () => {
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
pendingOAuthTools = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
if (selectedModelIds.filter((id) => id).length > 0) {
await setDefaults();
}
};
/** Check whether a terminal ID references an available system or direct terminal. */
const isTerminalAvailable = (tid: string): boolean => {
return (
($terminalServers ?? []).some((t) => t.id && t.id === tid) ||
($settings?.terminalServers ?? []).some((s) => s.url === tid)
);
};
let settingDefaults = false;
const setDefaults = async () => {
if (settingDefaults) return;
settingDefaults = true;
try {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (!$skills) {
skills.set(await getSkills(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) {
return;
}
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) {
// Set Default Tools
if (model?.info?.meta?.toolIds) {
const defaultIds = [
...new Set(
[...(model?.info?.meta?.toolIds ?? [])].filter((id) =>
$tools.find((t) => t.id === id)
)
)
];
// Separate unauthenticated OAuth tools
const unauthed = [];
const authed = [];
for (const id of defaultIds) {
const tool = $tools.find((t) => t.id === id);
if (tool && tool.authenticated === false) {
const parts = id.split(':');
const serverId = parts.at(-1) ?? id;
const authType =
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
} else {
authed.push(id);
}
}
selectedToolIds = authed;
pendingOAuthTools = unauthed;
await continueOAuthRedirect();
} else if ($settings?.tools) {
selectedToolIds = $settings.tools;
} else {
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
// Set Default Skills
if (model?.info?.meta?.skillIds) {
selectedSkillIds = [
...new Set(
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
($skills ?? []).find((s) => s.id === id && s.is_active)
)
)
];
} else {
selectedSkillIds = [];
}
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
model?.filters?.find((f) => f.id === id)
);
}
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (
model.info?.meta?.capabilities?.['image_generation'] &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
if (
model.info?.meta?.capabilities?.['web_search'] &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
if (
model.info?.meta?.capabilities?.['code_interpreter'] &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
// Set Default Terminal — only if the referenced terminal actually exists
if (model?.info?.meta?.terminalId) {
const tid = model.info.meta.terminalId;
if (isTerminalAvailable(tid)) {
selectedTerminalId.set(tid);
}
}
}
} finally {
settingDefaults = false;
}
};
const showMessage = async (message, scroll = true, save = true) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
let messageChildrenIds = [];
if (_messageId === null) {
messageChildrenIds = Object.keys(history.messages).filter(
(id) => history.messages[id].parentId === null
);
} else {
messageChildrenIds = history.messages[_messageId].childrenIds;
}
while (messageChildrenIds.length !== 0) {
_messageId = messageChildrenIds.at(-1);
messageChildrenIds = history.messages[_messageId].childrenIds;
}
history.currentId = _messageId;
await tick();
if (($settings?.scrollOnBranchChange ?? true) && scroll) {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
await tick();
await tick();
await tick();
if (save) {
saveChatHandler(_chatId, history);
}
};
const updateLastReadAt = (id) => {
$socket?.emit('events:chat', {
chat_id: id,
data: { type: 'last_read_at' }
});
};
const terminalEventHandler = (type: string, data: any) => {
if (type === 'terminal:display_file') {
if (!data?.path) return;
displayFileHandler(data.path, { showControls, showFileNavPath });
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
if (!data?.path) return;
showFileNavDir.set(data.path);
} else if (type === 'terminal:run_command') {
showFileNavDir.set('/');
}
};
const dismissContextCompactionToast = () => {
if (contextCompactionToastId !== null) {
toast.dismiss(contextCompactionToastId);
contextCompactionToastId = null;
}
};
const handleContextCompactionStatus = (status) => {
if (status?.action !== 'context_compaction') {
return;
}
if (status?.done) {
if (contextCompactionToastId !== null) {
if (status?.error) {
toast.error($i18n.t('Context compaction failed'), {
id: contextCompactionToastId,
duration: 3000
});
} else {
toast.success($i18n.t('Context compacted'), {
id: contextCompactionToastId,
duration: 1800
});
}
contextCompactionToastId = null;
}
return;
}
if (contextCompactionToastId === null) {
contextCompactionToastId = toast.loading($i18n.t('Compacting context'), {
duration: Infinity
});
}
};
const chatEventHandler = async (event, cb) => {
console.log(event);
if (event.chat_id === $chatId) {
await tick();
const type = event?.data?.type ?? null;
if (type === 'chat:reload') {
await loadChat();
return;
}
let message = history.messages[event.message_id];
if (message) {
const data = event?.data?.data ?? null;
if (type === 'status') {
if (message?.statusHistory) {
message.statusHistory.push(data);
} else {
message.statusHistory = [data];
}
} else if (type === 'context_compaction') {
handleContextCompactionStatus(data);
} else if (type === 'chat:active') {
if (!data?.active) {
taskIds = null;
if ($chatId && !$temporaryChatEnabled && hasPendingAssistantLeaf()) {
await loadChat();
}
}
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:tasks:cancel') {
dismissContextCompactionToast();
if (event.message_id === history.currentId) {
taskIds = null;
// Set all response messages to done
for (const messageId of history.messages[message.parentId].childrenIds) {
history.messages[messageId].done = true;
}
await processNextInQueue($chatId);
} else {
message.done = true;
}
} else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content;
} else if (type === 'chat:message' || type === 'replace') {
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:message:tasks') {
chatTasks = data.tasks;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
// Auto-scroll to the embed once it's rendered in the DOM
await tick();
setTimeout(() => {
const embedEl = document.getElementById(`${event.message_id}-embeds-container`);
if (embedEl) {
embedEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} else if (type === 'chat:message:error') {
message.error = data.error;
} else if (type === 'chat:message:follow_ups') {
message.followUps = data.follow_ups;
if (autoScroll) {
scrollToBottom('smooth');
}
} else if (type === 'chat:outlet') {
// Outlet filter ran on backend — sync in-memory state
const outletMessages = data.messages ?? [];
for (const msg of outletMessages) {
if (msg?.id && history.messages[msg.id]) {
const existing = history.messages[msg.id];
if (existing.content !== msg.content) {
history.messages[msg.id] = {
...existing,
originalContent: existing.content,
...msg
};
}
}
}
history = history;
return; // Patches history.messages directly; skip the trailing write-back.
} else if (type === 'chat:message:favorite') {
// Update message favorite status
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);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'source' || type === 'citation') {
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
if (!message?.code_executions) {
message.code_executions = [];
}
const existingCodeExecutionIndex = message.code_executions.findIndex(
(execution) => execution.id === data.id
);
if (existingCodeExecutionIndex !== -1) {
message.code_executions[existingCodeExecutionIndex] = data;
} else {
message.code_executions.push(data);
}
message.code_executions = message.code_executions;
} else {
// Regular source.
if (message?.sources) {
message.sources.push(data);
} else {
message.sources = [data];
}
}
} else if (type === 'notification') {
const toastType = data?.type ?? 'info';
const toastContent = data?.content ?? '';
if (toastType === 'success') {
toast.success(toastContent);
} else if (toastType === 'error') {
toast.error(toastContent);
} else if (toastType === 'warning') {
toast.warning(toastContent);
} else {
toast.info(toastContent);
}
} else if (type === 'confirmation') {
eventCallback = cb;
eventConfirmationInput = false;
showEventConfirmation = true;
eventConfirmationInputOptions = [];
eventConfirmationTitle = data.title;
eventConfirmationMessage = data.message;
} else if (type === 'execute') {
eventCallback = cb;
try {
// Use Function constructor to evaluate code in a safer way
const asyncFunction = new Function(`return (async () => { ${data.code} })()`);
const result = await asyncFunction(); // Await the result of the async function
if (cb) {
cb(result);
}
} catch (error) {
console.error('Error executing code:', error);
}
} else if (type === 'input') {
eventCallback = cb;
eventConfirmationInput = true;
showEventConfirmation = true;
eventConfirmationTitle = data.title;
eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? '';
eventConfirmationInputType = data?.input?.type ?? data?.type ?? '';
eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? [];
} else if (type.startsWith('terminal:')) {
terminalEventHandler(type, data);
} else {
console.log('Unknown message type', data);
}
history.messages[event.message_id] = message;
}
} else {
// Non-active chat completion: queue stays in the global store.
// navigateHandler will process it when the user returns to that chat.
}
};
const onMessageHandler = async (event: {
origin: string;
source: unknown;
data: { type: string; text: string };
}) => {
const isSameOrigin = event.origin === window.origin;
const type = event.data?.type;
// Prompt-driving types are trusted only same-origin, from our own embed iframes
// (opaque srcdoc origin, submission still confirmed below) or via explicit opt-in.
const promptTypes = ['input:prompt', 'input:prompt:submit', 'action:submit'];
const isOwnEmbed = isEmbedWindow(event.source);
const isTrusted =
isSameOrigin || isOwnEmbed || ($settings?.iframeSandboxAllowSameOrigin ?? false);
// Non-prompt message types are always restricted to same-origin only.
if (!isSameOrigin && !promptTypes.includes(type)) {
return;
}
// Prompt types from an untrusted cross-origin source are silently dropped.
if (promptTypes.includes(type) && !isTrusted) {
return;
}
if (type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
if (isSameOrigin) {
await tick();
submitHandler(prompt);
} else {
eventConfirmationInput = false;
eventConfirmationTitle = $i18n.t('Confirm Prompt from Embed');
eventConfirmationMessage = prompt;
eventCallback = async (confirmed: boolean) => {
if (confirmed) {
await tick();
submitHandler(prompt);
}
};
showEventConfirmation = true;
}
}
}
if (type === 'input:prompt') {
console.debug(event.data.text);
const inputElement = document.getElementById('chat-input');
if (inputElement) {
messageInput?.setText(event.data.text);
inputElement.focus();
}
}
if (type === 'input:prompt:submit') {
console.debug(event.data.text);
if (event.data.text !== '') {
if (isSameOrigin) {
await tick();
submitHandler(event.data.text);
} else {
eventConfirmationInput = false;
eventConfirmationTitle = $i18n.t('Confirm Prompt from Embed');
eventConfirmationMessage = event.data.text;
eventCallback = async (confirmed: boolean) => {
if (confirmed) {
await tick();
submitHandler(event.data.text);
}
};
showEventConfirmation = true;
}
}
}
};
const savedModelIds = async () => {
if (
$selectedFolder &&
selectedModels.filter((modelId) => modelId !== '').length > 0 &&
!equal($selectedFolder?.data?.model_ids, selectedModels)
) {
const res = await updateFolderById(localStorage.token, $selectedFolder.id, {
data: {
model_ids: selectedModels
}
});
}
};
$: if (selectedModels !== null) {
savedModelIds();
}
const stopAudio = () => {
try {
speechSynthesis.cancel();
$audioQueue?.stop();
} catch {}
};
const hasPendingAssistantLeaf = () =>
Object.values(history.messages).some(
(message) =>
message?.role === 'assistant' && !message.done && (message.childrenIds?.length ?? 0) === 0
);
const handleSocketConnect = async () => {
// Gate on $chatId, not chatIdProp: chats started from the home page keep an empty chatIdProp
if (!$chatId || $temporaryChatEnabled) {
return;
}
if (!hasPendingAssistantLeaf()) {
return;
}
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
.then((res) => res?.task_ids ?? [])
.catch(() => null);
if (pendingTaskIds?.length === 0) {
await loadChat();
}
};
onMount(() => {
loading = true;
console.log('mounted');
window.addEventListener('message', onMessageHandler);
$socket?.on('events', chatEventHandler);
$socket?.on('connect', handleSocketConnect);
$audioQueue?.destroy();
const audioQueueInstance = new AudioQueue(document.getElementById('audioElement'));
audioQueue.set(audioQueueInstance);
// Restore direct terminal enabled states based on persisted selectedTerminalId
if ($settings?.terminalServers?.length) {
settings.set({
...$settings,
terminalServers: ($settings.terminalServers ?? []).map((s) => ({
...s,
enabled: $selectedTerminalId !== null && s.url === $selectedTerminalId
}))
});
}
// Clear stale selectedTerminalId if the referenced terminal no longer exists
if ($selectedTerminalId && !isTerminalAvailable($selectedTerminalId)) {
selectedTerminalId.set(null);
}
const pageSubscribe = page.subscribe(async (p) => {
if (p.url.pathname === '/') {
await tick();
initNewChat();
}
stopAudio();
});
const showControlsSubscribe = showControls.subscribe(async (value) => {
await tick();
if (controlPane && !$mobile) {
try {
if (value) {
controlPaneComponent?.openPane();
} else {
controlPane.collapse();
}
} catch (e) {
// ignore
}
}
if (!value) {
showCallOverlay.set(false);
showArtifacts.set(false);
showEmbeds.set(false);
}
});
const selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
await tick();
if (folder?.data?.model_ids && !equal(selectedModels, folder.data.model_ids)) {
selectedModels = folder.data.model_ids;
console.log('Set selectedModels from folder data:', selectedModels);
}
});
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
const init = async () => {
if (!chatIdProp) {
loading = false;
await tick();
}
if (storageChatInput) {
prompt = '';
messageInput?.setText('');
files = [];
selectedToolIds = [];
selectedSkillIds = [];
selectedFilterIds = [];
webSearchEnabled = false;
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
try {
const input = JSON.parse(storageChatInput);
if (!$temporaryChatEnabled) {
messageInput?.setText(input.prompt);
files = input.files;
selectedToolIds = input.selectedToolIds;
selectedSkillIds = input.selectedSkillIds ?? [];
selectedFilterIds = input.selectedFilterIds;
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
}
} catch (e) {}
}
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
};
init();
return () => {
try {
clearTimeout(saveControlsTimer);
saveControls();
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
}
pageSubscribe();
showControlsSubscribe();
selectedFolderSubscribe();
window.removeEventListener('message', onMessageHandler);
$socket?.off('events', chatEventHandler);
$socket?.off('connect', handleSocketConnect);
dismissContextCompactionToast();
audioQueueInstance?.destroy();
audioQueue.set(null);
} catch (e) {
console.error(e);
}
};
});
// File upload functions
const uploadGoogleDriveFile = async (fileData) => {
console.log('Starting uploadGoogleDriveFile with:', {
id: fileData.id,
name: fileData.name,
url: fileData.url,
headers: {
Authorization: `Bearer ${token}`
}
});
// Validate input
if (!fileData?.id || !fileData?.name || !fileData?.url || !fileData?.headers?.Authorization) {
throw new Error('Invalid file data provided');
}
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
file: '',
id: null,
url: fileData.url,
name: fileData.name,
collection_name: '',
status: 'uploading',
error: '',
itemId: tempItemId,
size: 0
};
try {
files = [...files, fileItem];
console.log('Processing web file with URL:', fileData.url);
// Configure fetch options with proper headers
const fetchOptions = {
headers: {
Authorization: fileData.headers.Authorization,
Accept: '*/*'
},
method: 'GET'
};
// Attempt to fetch the file
console.log('Fetching file content from Google Drive...');
const fileResponse = await fetch(fileData.url, fetchOptions);
if (!fileResponse.ok) {
const errorText = await fileResponse.text();
throw new Error(`Failed to fetch file (${fileResponse.status}): ${errorText}`);
}
// Get content type from response
const contentType = fileResponse.headers.get('content-type') || 'application/octet-stream';
console.log('Response received with content-type:', contentType);
// Convert response to blob
console.log('Converting response to blob...');
const fileBlob = await fileResponse.blob();
if (fileBlob.size === 0) {
throw new Error('Retrieved file is empty');
}
console.log('Blob created:', {
size: fileBlob.size,
type: fileBlob.type || contentType
});
// Create File object with proper MIME type
const file = new File([fileBlob], fileData.name, {
type: fileBlob.type || contentType
});
console.log('File object created:', {
name: file.name,
size: file.size,
type: file.type
});
if (file.size === 0) {
throw new Error('Created file is empty');
}
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
// Upload file to server
console.log('Uploading file to server...');
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
if (!uploadedFile) {
throw new Error('Server returned null response for file upload');
}
console.log('File uploaded successfully:', uploadedFile);
// Update file item with upload results
fileItem.status = 'uploaded';
fileItem.file = uploadedFile;
fileItem.id = uploadedFile.id;
fileItem.size = file.size;
fileItem.collection_name = uploadedFile?.meta?.collection_name;
fileItem.url = `${uploadedFile.id}`;
files = files;
toast.success($i18n.t('File uploaded successfully'));
} catch (e) {
console.error('Error uploading file:', e);
files = files.filter((f) => f.itemId !== tempItemId);
toast.error(
$i18n.t('Error uploading file: {{error}}', {
error: e.message || 'Unknown error'
})
);
}
};
const uploadWeb = async (urls) => {
if ($user?.role !== 'admin' && !($user?.permissions?.chat?.web_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload web content.'));
return;
}
if (!Array.isArray(urls)) {
urls = [urls];
}
// Create file items first
const fileItems = urls.map((url) => ({
type: 'text',
name: url,
collection_name: '',
status: 'uploading',
context: 'full',
url,
error: ''
}));
// Display all items at once
files = [...files, ...fileItems];
for (const fileItem of fileItems) {
try {
const res = isYoutubeUrl(fileItem.url)
? await processYoutubeVideo(localStorage.token, fileItem.url)
: await processWeb(localStorage.token, '', fileItem.url);
if (res) {
fileItem.status = 'uploaded';
fileItem.collection_name = res.collection_name;
fileItem.file = {
...res.file,
...fileItem.file
};
}
files = [...files];
} catch (e) {
files = files.filter((f) => f.name !== url);
toast.error(`${e}`);
}
}
};
const onUpload = async (event) => {
const { type, data } = event;
if (type === 'google-drive') {
await uploadGoogleDriveFile(data);
} else if (type === 'web') {
await uploadWeb(data);
}
};
const onHistoryChange = (history) => {
if (history) {
clearTimeout(contentsRAF);
contentsRAF = setTimeout(() => {
getContents();
contentsRAF = null;
}, 0);
} else {
artifactContents.set([]);
}
};
$: onHistoryChange(history);
const dispatchCallOverlayAudio = (message, final = false) => {
if (!$showCallOverlay) {
return;
}
const messageContentParts = getMessageContentParts(
getOutputText(message?.output) || removeAllDetails(message?.content ?? ''),
$config?.audio?.tts?.split_on ?? 'punctuation'
);
if (!final) {
messageContentParts.pop();
}
const nextContentPart = messageContentParts.at(-1) ?? '';
if (!nextContentPart || (!final && nextContentPart === message.lastSentence)) {
return;
}
if (!final) {
message.lastSentence = nextContentPart;
}
eventTarget.dispatchEvent(
new CustomEvent('chat', {
detail: {
id: message.id,
content: nextContentPart
}
})
);
};
const getContents = () => {
const messages = history ? createMessagesList(history, history.currentId) : [];
let contents = [];
messages.forEach((message) => {
if (message?.role !== 'user') {
const messageContent =
getOutputText(message?.output) || removeAllDetails(message?.content ?? '');
if (!messageContent.trim()) {
return;
}
const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } =
getCodeBlockContents(messageContent);
if (htmlGroups && htmlGroups.length > 0) {
htmlGroups.forEach((group) => {
const renderedContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<${''}style>
body {
background-color: white; /* Ensure the iframe has a white background */
}
${group.css}
</${''}style>
</head>
<body>
${group.html}
<${''}script>
${group.js}
</${''}script>
</body>
</html>
`;
contents = [...contents, { type: 'iframe', content: renderedContent }];
});
} else {
// Check for SVG content
for (const block of codeBlocks) {
if (block.lang === 'svg' || (block.lang === 'xml' && block.code.includes('<svg'))) {
contents = [...contents, { type: 'svg', content: block.code }];
}
}
}
}
});
artifactContents.set(contents);
};
//////////////////////////
// Web functions
//////////////////////////
const initNewChat = async () => {
console.log('initNewChat');
resetWebSearchConfirmation();
// Mark the outgoing chat as read before resetting; in-place created chats
// keep chatIdProp undefined, so navigateHandler never marks them read.
if ($chatId && !$temporaryChatEnabled) {
updateLastReadAt($chatId);
}
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
}
if ($settings?.temporaryChatByDefault ?? false) {
if ($temporaryChatEnabled === false) {
await temporaryChatEnabled.set(true);
} else if ($temporaryChatEnabled === null) {
// if set to null set to false; refer to temp chat toggle click handler
await temporaryChatEnabled.set(false);
}
}
if ($user?.role !== 'admin' && !$user?.permissions?.chat?.temporary) {
await temporaryChatEnabled.set(false);
}
const availableModels = $models
.filter((m) => !(m?.info?.meta?.hidden ?? false))
.map((m) => m.id);
const defaultModels = $config?.default_models ? $config?.default_models.split(',') : [];
if ($page.url.searchParams.get('models') || $page.url.searchParams.get('model')) {
const urlModels = (
$page.url.searchParams.get('models') ||
$page.url.searchParams.get('model') ||
''
)?.split(',');
if (urlModels.length === 1) {
if (!$models.find((m) => m.id === urlModels[0])) {
// Model not found; open model selector and prefill
const modelSelectorButton = document.getElementById('model-selector-0-button');
if (modelSelectorButton) {
modelSelectorButton.click();
await tick();
const modelSelectorInput = document.getElementById('model-search-input');
if (modelSelectorInput) {
modelSelectorInput.focus();
modelSelectorInput.value = urlModels[0];
modelSelectorInput.dispatchEvent(new Event('input'));
}
}
} else {
// Model found; set it as selected
selectedModels = urlModels;
}
} else {
// Multiple models; set as selected
selectedModels = urlModels;
}
// Unavailable models filtering
selectedModels = selectedModels.filter((modelId) =>
$models.map((m) => m.id).includes(modelId)
);
} else {
if ($selectedFolder?.data?.model_ids) {
// Set from folder model IDs
selectedModels = $selectedFolder?.data?.model_ids;
} else {
if (sessionStorage.selectedModels) {
// Set from session storage (temporary selection)
selectedModels = JSON.parse(sessionStorage.selectedModels);
sessionStorage.removeItem('selectedModels');
} else {
if ($settings?.models) {
// Set from user settings
selectedModels = $settings?.models;
} else if (defaultModels && defaultModels.length > 0) {
// Set from default models
selectedModels = defaultModels;
}
}
}
// Unavailable & hidden models filtering
selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId));
}
// Ensure at least one model is selected
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
if (availableModels.length > 0) {
if (defaultModels && defaultModels.length > 0) {
selectedModels = defaultModels.filter((modelId) => availableModels.includes(modelId));
}
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '')
) {
// Only fall back to first available model if default models didn't resolve
selectedModels = [availableModels?.at(0) ?? ''];
}
} else {
selectedModels = [''];
}
}
if ($mobile) {
await showControls.set(false);
}
await showCallOverlay.set(false);
await showArtifacts.set(false);
if (!embedded && $page.url.pathname.includes('/c/')) {
window.history.replaceState(history.state, '', `/`);
}
autoScroll = true;
await resetInput();
await chatId.set('');
await chatTitle.set('');
history = {
messages: {},
currentId: null
};
chatFiles = [];
params = {};
taskIds = null;
chatTasks = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
}
if ($page.url.searchParams.get('load-url')) {
await uploadWeb($page.url.searchParams.get('load-url'));
}
if ($page.url.searchParams.get('web-search') === 'true') {
webSearchEnabled = true;
}
if ($page.url.searchParams.get('image-generation') === 'true') {
imageGenerationEnabled = true;
}
if ($page.url.searchParams.get('code-interpreter') === 'true') {
codeInterpreterEnabled = true;
}
if ($page.url.searchParams.get('tools')) {
selectedToolIds = $page.url.searchParams
.get('tools')
?.split(',')
.map((id) => id.trim())
.filter((id) => id);
} else if ($page.url.searchParams.get('tool-ids')) {
selectedToolIds = $page.url.searchParams
.get('tool-ids')
?.split(',')
.map((id) => id.trim())
.filter((id) => id);
}
// Restore tool selection after OAuth redirect
const pendingToolId = sessionStorage.getItem('pendingOAuthToolId');
if (pendingToolId) {
sessionStorage.removeItem('pendingOAuthToolId');
if (!selectedToolIds.includes(pendingToolId)) {
selectedToolIds = [...selectedToolIds, pendingToolId];
}
}
if ($page.url.searchParams.get('call') === 'true') {
showCallOverlay.set(true);
showControls.set(true);
}
// Consume one-shot desktop event (e.g. Spotlight query, call shortcut)
if ($desktopEvent) {
const event = $desktopEvent;
desktopEvent.set(null);
if (event.type === 'call') {
// Defer to next macrotask so the call overlay isn't clobbered by
// showControlsSubscribe's initial callback (value=false → set(false))
// which runs as a pending microtask after this function.
setTimeout(() => {
showCallOverlay.set(true);
showControls.set(true);
}, 0);
} else if (event.type === 'query') {
const query = event.data?.query;
const eventFiles = event.data?.files;
// Attach screenshot images from desktop (e.g. Spotlight region capture)
if (eventFiles?.length) {
for (const ef of eventFiles) {
files = [
...files,
{
type: 'image',
url: ef.dataUrl,
name: ef.name
}
];
}
}
if (query || eventFiles?.length) {
if (query) {
messageInput?.setText(query);
}
await tick();
submitHandler(query || '');
}
}
} else if ($page.url.searchParams.get('q')) {
const q = $page.url.searchParams.get('q') ?? '';
messageInput?.setText(q);
if (q) {
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
await tick();
submitHandler(q);
}
}
}
selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
const chatInput = document.getElementById('chat-input');
setTimeout(() => chatInput?.focus(), 0);
};
const loadChat = async () => {
noteChatDebug('loadChat start');
// chatIdProp is empty for chats started from the home page (URL set via replaceState)
chatId.set(chatIdProp || $chatId);
noteChatDebug('loadChat set active chat id');
if ($temporaryChatEnabled) {
noteChatDebug('loadChat disabling temporary chat');
temporaryChatEnabled.set(false);
}
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
console.error('[note-chat] getChatById failed', {
chatIdProp,
activeChatId: $chatId,
error
});
if (!embedded) {
await goto('/');
}
return null;
});
noteChatDebug('getChatById completed', {
found: !!chat,
chatId: chat?.id,
hasChatPayload: !!chat?.chat,
title: chat?.title
});
if (chat) {
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
console.warn('[note-chat] getTagsById failed; continuing without tags', {
chatIdProp,
activeChatId: $chatId,
error
});
return [];
});
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
const chatContent = chat.chat;
if (chatContent) {
noteChatDebug('chat payload found', {
models: chatContent?.models,
hasHistory: !!chatContent?.history,
messageCount: Object.keys(chatContent?.history?.messages ?? {}).length
});
selectedModels =
(chatContent?.models ?? undefined) !== undefined
? chatContent.models
: [chatContent.models ?? ''];
if (!($user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true))) {
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);
history =
(chatContent?.history ?? undefined) !== undefined
? chatContent.history
: convertMessagesToHistory(chatContent.messages);
// Sanitize history: repair orphaned references and structurally-malformed
// nodes from failed regenerations (#24424, #24157, #20474)
sanitizeHistory(history);
chatTitle.set(chatContent.title);
params = structuredClone(chatContent?.params ?? {});
delete params.note_id;
chatFiles = structuredClone(chatContent?.files ?? []);
// Load tasks from chat-level DB field
chatTasks = chat?.tasks ?? [];
serverContextUsage = chat?.context_usage ?? null;
autoScroll = true;
await tick();
// Mark all non-current assistant messages as done
if (history.currentId) {
for (const message of Object.values(history.messages)) {
if (
message &&
message.role === 'assistant' &&
message.id !== history.currentId &&
message.done !== false
) {
message.done = true;
}
}
}
// Reconcile active tasks with message state:
// If the response is already done, remaining tasks are just background
// work (follow-ups, title gen) that shouldn't block the input.
const activeTaskIds = taskIds;
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
.then((res) => res?.task_ids ?? [])
.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) {
noteChatDebug('task ids changed during load; aborting stale load');
return;
}
const responseComplete = currentMessage?.role === 'assistant' && currentMessage?.done;
if (pendingTaskIds.length > 0 && !responseComplete) {
taskIds = pendingTaskIds;
} else {
taskIds = null;
// No active tasks and message incomplete → generation was interrupted
if (currentMessage?.role === 'assistant' && !currentMessage.done) {
currentMessage.done = true;
}
}
await tick();
return true;
} else {
console.warn('[note-chat] chat response missing chat payload', {
chatIdProp,
activeChatId: $chatId,
chat
});
return null;
}
}
console.warn('[note-chat] no chat returned from getChatById', {
chatIdProp,
activeChatId: $chatId
});
};
const scrollToBottom = async (behavior = 'auto') => {
await tick();
if (messagesContainerElement) {
messagesContainerElement.scrollTo({
top: messagesContainerElement.scrollHeight,
behavior
});
// content-visibility: auto causes the initial scrollHeight to be based on
// estimated sizes (contain-intrinsic-size). After we scroll, previously
// off-screen messages become visible and the browser resolves their actual
// heights, which shifts scrollHeight. Re-layouts can cascade across frames
// (new sizes reveal more content, triggering further size resolution), so
// we re-scroll across two animation frames to land at the true bottom.
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTo({
top: messagesContainerElement.scrollHeight,
behavior
});
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTo({
top: messagesContainerElement.scrollHeight,
behavior
});
}
});
}
});
}
};
const scrollToTop = async () => {
await messagesRef?.scrollToTop();
};
let scrollRAF = null;
let contentsRAF = null;
const scheduleScrollToBottom = () => {
if (!scrollRAF) {
scrollRAF = requestAnimationFrame(async () => {
scrollRAF = null;
await scrollToBottom();
});
}
};
let processingQueueChats = new Set<string>();
const processNextInQueue = async (targetChatId: string) => {
if (processingQueueChats.has(targetChatId)) return;
const queue = $chatRequestQueues[targetChatId];
if (!queue || queue.length === 0) return;
processingQueueChats.add(targetChatId);
try {
const combinedPrompt = queue.map((m) => m.prompt).join('\n\n');
const combinedFiles = queue.flatMap((m) => m.files);
chatRequestQueues.update((q) => {
const { [targetChatId]: _, ...rest } = q;
return rest;
});
await submitPrompt(combinedPrompt, combinedFiles);
} finally {
processingQueueChats.delete(targetChatId);
}
};
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
// Backend handles outlet filters and persistence inline.
// Just refresh the sidebar chat list.
if ($chatId == _chatId && !$temporaryChatEnabled) {
await refreshChatList(localStorage.token);
}
};
const chatActionHandler = async (_chatId, actionId, modelId, responseMessageId, event = null) => {
const messages = createMessagesList(history, responseMessageId);
const res = await chatAction(localStorage.token, actionId, {
model: modelId,
messages: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
info: m.info ? m.info : undefined,
timestamp: m.timestamp,
...(m.sources ? { sources: m.sources } : {})
})),
...(event ? { event: event } : {}),
model_item: $models.find((m) => m.id === modelId),
chat_id: _chatId,
session_id: $socket?.id,
id: responseMessageId
}).catch((error) => {
toast.error(`${error}`);
messages.at(-1).error = { content: error };
return null;
});
if (res !== null && res.messages) {
// Update chat history with the new messages
for (const message of res.messages) {
history.messages[message.id] = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
: {}),
...message
};
}
}
if ($chatId == _chatId) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
models: selectedModels,
messages: messages,
history: history,
params: params,
files: chatFiles
});
await refreshChatList(localStorage.token);
}
}
};
const getChatEventEmitter = async (modelId: string, chatId: string = '') => {
return setInterval(() => {
$socket?.emit('usage', {
action: 'chat',
model: modelId,
chat_id: chatId
});
}, 1000);
};
const createMessagePair = async (userPrompt) => {
messageInput?.setText('');
if (selectedModels.length === 0) {
toast.error($i18n.t('Model not selected'));
} else {
const modelId = selectedModels[0];
const model = $models.filter((m) => m.id === modelId).at(0);
if (!model) {
toast.error($i18n.t('Model not found'));
return;
}
const messages = createMessagesList(history, history.currentId);
const parentMessage = messages.length !== 0 ? messages.at(-1) : null;
const userMessageId = uuidv4();
const responseMessageId = uuidv4();
const userMessage = {
id: userMessageId,
parentId: parentMessage ? parentMessage.id : null,
childrenIds: [responseMessageId],
role: 'user',
content: userPrompt ? userPrompt : `[PROMPT] ${userMessageId}`,
timestamp: Math.floor(Date.now() / 1000)
};
const responseMessage = {
id: responseMessageId,
parentId: userMessageId,
childrenIds: [],
role: 'assistant',
content: `[RESPONSE] ${responseMessageId}`,
done: true,
model: modelId,
modelName: model.name ?? model.id,
modelIdx: 0,
timestamp: Math.floor(Date.now() / 1000)
};
if (parentMessage) {
parentMessage.childrenIds.push(userMessageId);
history.messages[parentMessage.id] = parentMessage;
}
history.messages[userMessageId] = userMessage;
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length === 0) {
await initChatHandler(history);
} else {
await saveChatHandler($chatId, history);
}
}
};
const addMessages = async ({ modelId, parentId, messages }) => {
const model = $models.filter((m) => m.id === modelId).at(0);
let parentMessage = history.messages[parentId];
let currentParentId = parentMessage ? parentMessage.id : null;
for (const message of messages) {
let messageId = uuidv4();
if (message.role === 'user') {
const userMessage = {
id: messageId,
parentId: currentParentId,
childrenIds: [],
timestamp: Math.floor(Date.now() / 1000),
...message
};
if (parentMessage) {
parentMessage.childrenIds.push(messageId);
history.messages[parentMessage.id] = parentMessage;
}
history.messages[messageId] = userMessage;
parentMessage = userMessage;
currentParentId = messageId;
} else {
const responseMessage = {
id: messageId,
parentId: currentParentId,
childrenIds: [],
done: true,
model: model.id,
modelName: model.name ?? model.id,
modelIdx: 0,
timestamp: Math.floor(Date.now() / 1000),
...message
};
if (parentMessage) {
parentMessage.childrenIds.push(messageId);
history.messages[parentMessage.id] = parentMessage;
}
history.messages[messageId] = responseMessage;
parentMessage = responseMessage;
currentParentId = messageId;
}
}
history.currentId = currentParentId;
await tick();
if (autoScroll) {
scrollToBottom();
}
if (messages.length === 0) {
await initChatHandler(history);
} else {
await saveChatHandler($chatId, history);
}
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
// Store raw OR-aligned output items from backend
if (output) {
message.output = output;
message.content = getOutputText(output);
dispatchCallOverlayAudio(message);
}
if (error) {
await handleOpenAIError(error, message);
}
if (sources && !message?.sources) {
message.sources = sources;
}
if (choices && !output) {
if (choices[0]?.message?.content) {
// Non-stream response
message.content += choices[0]?.message?.content;
dispatchCallOverlayAudio(message);
} else {
// Stream response
let value = choices[0]?.delta?.content ?? '';
if (message.content == '' && value == '\n') {
console.log('Empty response');
} else {
message.content += value;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
}
}
}
if (content && !output) {
// REALTIME_CHAT_SAVE is disabled
message.content = content;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
}
if (selected_model_id) {
message.selectedModelId = selected_model_id;
message.arena = true;
}
if (usage) {
message.usage = usage;
}
history.messages[message.id] = message;
if (done) {
message.done = true;
const visibleContent =
getOutputText(message?.output) || removeAllDetails(message?.content ?? '');
if ($settings.responseAutoCopy) {
copyToClipboard(visibleContent);
}
if ($settings.responseAutoPlayback && !$showCallOverlay) {
await tick();
document.getElementById(`speak-button-${message.id}`)?.click();
}
// Emit chat event for TTS (only when call overlay is active)
dispatchCallOverlayAudio(message, true);
eventTarget.dispatchEvent(
new CustomEvent('chat:finish', {
detail: {
id: message.id,
content: visibleContent
}
})
);
history.messages[message.id] = message;
await tick();
if (autoScroll) {
scrollToBottom();
}
// Fire-and-forget: run chatCompletedHandler for background work
// (outlet filters, chat save, title gen, follow-ups, tags)
// without blocking the user from sending new messages.
chatCompletedHandler(
chatId,
message.model,
message.id,
createMessagesList(history, message.id)
);
// Process next queued request if any
await processNextInQueue(chatId);
}
console.log(data);
await tick();
if (autoScroll) {
scheduleScrollToBottom();
}
};
//////////////////////////
// Chat functions
//////////////////////////
const submitPrompt = async (inputContent, inputFiles) => {
const _files = structuredClone(inputFiles);
chatFiles.push(
..._files.filter(
(item) =>
['doc', 'text', 'note', 'chat', 'folder', 'collection'].includes(item.type) ||
(item.type === 'file' && !(item?.content_type ?? '').startsWith('image/'))
)
);
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) => array.findIndex((i) => equal(i, item)) === index
);
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: history.currentId ?? null,
childrenIds: [],
role: 'user',
content: inputContent,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
// Append messageId to childrenIds of parent message
if (history.currentId !== null) {
history.messages[history.currentId].childrenIds.push(userMessageId);
}
history.currentId = userMessageId;
// focus on chat input (skip during voice call to avoid triggering mobile keyboard)
if (!$showCallOverlay) {
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
}
saveSessionSelectedModels();
await sendMessage(history, userMessageId);
};
const handleManualCompact = async () => {
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to compact'));
return;
}
const currentMessage = history.messages?.[history.currentId];
if (
generating ||
taskIds?.length ||
(currentMessage?.role === 'assistant' && !currentMessage.done)
) {
toast.warning($i18n.t('Wait for the current response to finish before compacting.'));
return;
}
const model = atSelectedModel?.id ?? selectedModels.find((modelId) => modelId);
const toastId = toast.loading($i18n.t('Compacting context...'));
try {
const result = await compactChatById(localStorage.token, $chatId, model);
serverContextUsage = result?.context_usage ?? serverContextUsage;
if (result?.compacted) {
toast.success($i18n.t('Context compacted'), { id: toastId });
} else {
const skippedReason =
result?.reason === 'too_short'
? $i18n.t('Chat is too short to compact')
: result?.reason === 'empty'
? $i18n.t('No chat to compact')
: $i18n.t('Nothing to compact');
toast.message(skippedReason, { id: toastId });
}
await loadChat();
} catch (error) {
const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed');
toast.error(message, { id: toastId });
} finally {
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus();
}
};
const handleStatusCommand = () => {
messageInput?.showStatus();
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus();
};
const handleForkChat = async () => {
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to fork'));
return;
}
if (!($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))) {
toast.error($i18n.t('Access prohibited'));
return;
}
const currentMessage = history.messages?.[history.currentId];
if (
generating ||
taskIds?.length ||
(currentMessage?.role === 'assistant' && !currentMessage.done)
) {
toast.warning($i18n.t('Wait for the current response to finish before forking.'));
return;
}
const toastId = toast.loading($i18n.t('Forking chat...'));
try {
const result = await cloneChatById(
localStorage.token,
$chatId,
$i18n.t('Clone of {{TITLE}}', {
TITLE: $chatTitle || 'Chat'
})
);
if (result?.id) {
if (!embedded) {
await goto(`/c/${result.id}`);
await refreshChatList(localStorage.token, { refreshPinned: true });
}
toast.success($i18n.t('Chat forked'), { id: toastId });
} else {
toast.error($i18n.t('Failed to fork chat'), { id: toastId });
}
} catch (error) {
toast.error(`${error}`, { id: toastId });
} finally {
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus();
}
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
console.log('submitHandler', userPrompt, $chatId);
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (!equal(selectedModels, _selectedModels)) {
selectedModels = _selectedModels;
}
if (String(userPrompt).trim() === '/compact') {
await handleManualCompact();
return;
}
if (String(userPrompt).trim() === '/status') {
handleStatusCommand();
return;
}
if (String(userPrompt).trim() === '/fork') {
await handleForkChat();
return;
}
if (pendingOAuthTools.length > 0) {
toast.warning($i18n.t('Please connect all required integrations before sending a message'));
return;
}
if (userPrompt === '' && files.length === 0) {
toast.error($i18n.t('Please enter a prompt'));
return;
}
if (selectedModels.includes('')) {
toast.error($i18n.t('Model not selected'));
return;
}
if (
files.length > 0 &&
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
) {
toast.error(
$i18n.t(`Oops! There are files still uploading. Please wait for the upload to complete.`)
);
return;
}
if (
($config?.file?.max_count ?? null) !== null &&
files.length + chatFiles.length > $config?.file?.max_count
) {
toast.error(
$i18n.t(`You can only chat with a maximum of {{maxCount}} file(s) at a time.`, {
maxCount: $config?.file?.max_count
})
);
return;
}
if (
$config?.features?.enable_web_search_confirmation &&
webSearchActive &&
!webSearchConfirmed
) {
pendingWebSearchPrompt = userPrompt ?? '';
openWebSearchConfirm();
return;
}
// Check if the assistant is still generating the main response
// (don't block on background tasks like title gen, follow-ups, tags)
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
const isGenerating = lastMessage && lastMessage.role === 'assistant' && !lastMessage.done;
if (isGenerating) {
if ($settings?.enableMessageQueue ?? true) {
// Enqueue the request
const _files = structuredClone(files);
chatRequestQueues.update((q) => ({
...q,
[$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }]
}));
// Clear input
messageInput?.setText('');
prompt = '';
files = [];
return;
} else {
// Interrupt: stop current generation and proceed
await stopResponse();
await tick();
}
}
if (history?.currentId) {
const currentMessage = history.messages[history.currentId];
if (currentMessage.error && !currentMessage.content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
}
// Clear input and submit
messageInput?.setText('');
prompt = '';
const _files = structuredClone(files);
files = [];
messageInput?.setText('');
await submitPrompt(userPrompt, _files);
};
const sendMessage = async (
_history,
parentId: string,
{
messages = null,
modelId = null,
modelIdx = null,
regenerationPrompt = null
}: {
messages?: any[] | null;
modelId?: string | null;
modelIdx?: number | null;
regenerationPrompt?: string | null;
} = {}
) => {
if (autoScroll) {
scrollToBottom();
}
let _chatId = JSON.parse(JSON.stringify($chatId));
_history = structuredClone(_history);
const responseMessageIds: Record<PropertyKey, string> = {};
// If modelId is provided, use it, else use selected model
let selectedModelIds = modelId
? [modelId]
: atSelectedModel !== undefined
? [atSelectedModel.id]
: selectedModels;
// Create response messages for each selected model
// Build message_ids list: [{model_id, message_id}, ...]
// Uses an array instead of a dict to support duplicate model IDs in side-by-side chat.
const messageIdsList: Array<{ model_id: string; message_id: string }> = [];
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
if (model) {
let responseMessageId = uuidv4();
let responseMessage = {
parentId: parentId,
id: responseMessageId,
childrenIds: [],
role: 'assistant',
content: '',
done: false,
model: model.id,
modelName: model.name ?? model.id,
modelIdx: modelIdx ? modelIdx : _modelIdx,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
// Add message to history and Set currentId to messageId
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
// Append messageId to childrenIds of parent message
if (parentId !== null && history.messages[parentId]) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
];
}
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
messageIdsList.push({ model_id: modelId, message_id: responseMessageId });
}
}
history = history;
// Empty embedded drafts create their backing chat only when the first message is sent.
if (!_chatId) {
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();
await onSelectEmbeddedChat?.(_chatId);
} else if ($temporaryChatEnabled) {
_chatId = `local:${$socket?.id}`;
await chatId.set(_chatId);
}
await tick();
}
await tick();
// Re-clone history so sendMessageSocket gets the response messages we just added
_history = structuredClone(history);
// Vision capability check
for (const mid of selectedModelIds) {
const model = $models.filter((m) => m.id === mid).at(0);
if (model) {
const hasImages = createMessagesList(_history, parentId).some((message) =>
message.files?.some(
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
)
);
if (
hasImages &&
!(model.info?.meta?.capabilities?.vision ?? true) &&
!imageGenerationEnabled
) {
toast.error(
$i18n.t('Model {{modelName}} is not vision capable', {
modelName: model.name ?? model.id
})
);
}
}
}
// Single request — backend fans out to all models
const primaryModelId = selectedModelIds[0];
const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0);
const primaryResponseMessageId = messageIdsList[0]?.message_id;
if (primaryModel && primaryResponseMessageId) {
const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId);
try {
scrollToBottom();
await sendMessageSocket(
primaryModel,
messages && messages.length > 0
? messages
: createMessagesList(_history, primaryResponseMessageId),
_history,
primaryResponseMessageId,
_chatId,
{
messageIdsList: selectedModelIds.length > 1 ? messageIdsList : undefined,
regenerationPrompt
}
);
} finally {
if (chatEventEmitter) clearInterval(chatEventEmitter);
}
}
};
const getFeatures = () => {
let features = {};
if ($config?.features)
features = {
voice: $showCallOverlay,
image_generation:
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search: webSearchActive
};
if ($settings?.memory ?? $config?.features?.enable_memories ?? false) {
features = { ...features, memory: true };
}
return features;
};
const getStopTokens = () => {
const stop = params?.stop ?? $settings?.params?.stop;
if (!stop) return undefined;
const tokens = Array.isArray(stop) ? stop : stop.split(',').map((s) => s.trim());
return tokens
.filter(Boolean)
.map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`)));
};
const sendMessageSocket = async (
model,
_messages,
_history,
responseMessageId,
_chatId,
{
messageIdsList,
regenerationPrompt,
continueResponse = false
}: {
messageIdsList?: Array<{ model_id: string; message_id: string }>;
regenerationPrompt?: string | null;
continueResponse?: boolean;
} = {}
) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
const chatMessageFiles = _messages
.filter((message) => message.files)
.flatMap((message) => message.files);
// Filter chatFiles to only include files that are in the chatMessageFiles
chatFiles = chatFiles.filter((item) => {
const fileExists = chatMessageFiles.some((messageFile) => messageFile.id === item.id);
return fileExists;
});
let files = structuredClone(chatFiles);
files.push(
...(userMessage?.files ?? []).filter(
(item) =>
['doc', 'text', 'note', 'chat', 'collection', 'folder'].includes(item.type) ||
(item.type === 'file' && !(item?.content_type ?? '').startsWith('image/'))
)
);
// Remove duplicates
files = files.filter((item, index, array) => array.findIndex((i) => equal(i, item)) === index);
scrollToBottom();
eventTarget.dispatchEvent(
new CustomEvent('chat:start', {
detail: {
id: responseMessageId
}
})
);
await tick();
let userLocation;
if ($settings?.userLocation) {
userLocation = await getAndUpdateUserLocation(localStorage.token).catch((err) => {
console.error(err);
return undefined;
});
}
const stream =
model?.info?.params?.stream_response ??
$settings?.params?.stream_response ??
params?.stream_response ??
true;
// Always include system prompt — backend extracts it and prepends to DB messages.
// Only temp chats need conversation messages (persisted chats load from DB).
let messages: any[] = [
params?.system || $settings.system
? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` }
: undefined
].filter(Boolean);
if ($temporaryChatEnabled) {
messages = [
...messages,
..._messages.map((message) => ({
...message,
...(message.output && message.role === 'assistant'
? { output: message.output }
: { content: processDetails(message.content) })
}))
].filter((message) => message);
messages = messages
.map((message) => {
const imageFiles = (message?.files ?? []).filter(
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
);
if (message.output && message.role === 'assistant') {
return { role: message.role, output: message.output };
}
if (message.role === 'user' && imageFiles.length > 0) {
return {
role: message.role,
content: [
{
type: 'text',
text: message?.merged?.content ?? message.content
},
...imageFiles.map((file) => ({
type: 'image_url',
image_url: {
url: file.url
}
}))
]
};
}
return {
role: message.role,
content: message?.merged?.content ?? message.content
};
})
.filter(
(message) =>
message?.role === 'user' || message?.content?.trim() || message?.output?.length
);
}
const toolIds = [];
const toolServerIds = [];
for (const toolId of selectedToolIds) {
if (toolId.startsWith('direct_server:')) {
let serverId = toolId.replace('direct_server:', '');
// Check if serverId is a number
if (!isNaN(parseInt(serverId))) {
toolServerIds.push(parseInt(serverId));
} else {
toolServerIds.push(serverId);
}
} else {
toolIds.push(toolId);
}
}
// Menu-selected skills are sent as IDs; inline <$skillId|label> mentions stay
// in the message so the backend can inject their full content.
const skillIds = [...selectedSkillIds];
// Use the user-selected terminal from the dropdown
const activeTerminalId = $selectedTerminalId ?? null;
// Only send terminal_id if the model has terminal capability enabled
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
stream: stream,
model: model.id,
...(messages.length > 0 ? { messages } : {}),
params: {
...$settings?.params,
...params,
stop: getStopTokens()
},
files: (files?.length ?? 0) > 0 ? files : undefined,
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: terminalEnabled ? (activeTerminalId ?? undefined) : undefined,
tool_servers: [
...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
...($terminalServers ?? []).filter((t) => !t.id)
],
features: getFeatures(),
variables: {
...getPromptVariables(
$user?.name,
$settings?.userLocation ? userLocation : undefined,
$user?.email
)
},
model_item: $models.find((m) => m.id === model.id),
session_id: $socket?.id,
chat_id: _chatId || undefined,
folder_id: $selectedFolder?.id ?? undefined,
id: responseMessageId,
...(messageIdsList ? { message_ids: messageIdsList } : {}),
parent_id: userMessage?.parentId ?? null,
user_message: userMessage,
...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}),
...(continueResponse ? { assistant_message_id: responseMessageId } : {}),
background_tasks: {
...(!$temporaryChatEnabled &&
(!_chatId ||
(embedded &&
(userMessage?.parentId ?? null) === null &&
createMessagesList(_history, responseMessageId).length === 2))
? {
title_generation: $settings?.title?.auto ?? true,
tags_generation: $settings?.autoTags ?? true
}
: {}),
follow_up_generation: $settings?.autoFollowUps ?? true
},
...(stream && (model.info?.meta?.capabilities?.usage ?? false)
? {
stream_options: {
include_usage: true
}
}
: {})
},
`${WEBUI_BASE_URL}/api`
).catch(async (error) => {
console.log(error);
let errorMessage = error;
if (error?.error?.message) {
errorMessage = error.error.message;
} else if (error?.message) {
errorMessage = error.message;
}
if (typeof errorMessage === 'object') {
errorMessage = $i18n.t(`Uh-oh! There was an issue with the response.`);
}
toast.error(`${errorMessage}`);
responseMessage.error = {
content: error
};
responseMessage.done = true;
history.messages[responseMessageId] = responseMessage;
history.currentId = responseMessageId;
return null;
});
if (res) {
if (res.error) {
await handleOpenAIError(res.error, responseMessage);
} else {
// Backend returns task_ids (multi-model) or task_id (single model)
const newTaskIds = res.task_ids ?? (res.task_id ? [res.task_id] : []);
if (newTaskIds.length > 0) {
taskIds = [...(taskIds ?? []), ...newTaskIds];
}
// Backend returns chat_id for new chats — set store + URL.
// Only update if the user hasn't navigated to a different chat
// while the request was in flight (prevents overwriting $chatId
// and causing spurious toast notifications / state duplication).
if (res.chat_id && $chatId !== res.chat_id && $chatId === _chatId) {
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled && !embedded) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
await refreshChatList(localStorage.token);
// Persist chat-level params (system prompt, advanced
// params) that the backend doesn't receive in the
// chat completion request. Files are now persisted
// by the backend at chat creation time.
if (Object.keys(params).length > 0) {
await updateChatById(localStorage.token, res.chat_id, {
params: params
});
}
}
}
}
}
await tick();
scrollToBottom();
};
const handleOpenAIError = async (error, responseMessage) => {
let errorMessage = '';
let innerError;
if (error) {
innerError = error;
}
console.error(innerError);
if ('detail' in innerError) {
// FastAPI error
toast.error(innerError.detail);
errorMessage = innerError.detail;
} else if ('error' in innerError) {
// OpenAI error
if ('message' in innerError.error) {
toast.error(innerError.error.message);
errorMessage = innerError.error.message;
} else {
toast.error(innerError.error);
errorMessage = innerError.error;
}
} else if ('message' in innerError) {
// OpenAI error
toast.error(innerError.message);
errorMessage = innerError.message;
}
responseMessage.error = {
content: $i18n.t(`Uh-oh! There was an issue with the response.`) + '\n' + errorMessage
};
responseMessage.done = true;
if (responseMessage.statusHistory) {
responseMessage.statusHistory = responseMessage.statusHistory.filter(
(status) => status.action !== 'knowledge_search'
);
}
history.messages[responseMessage.id] = responseMessage;
};
const stopResponse = async (processQueue = true) => {
if (taskIds) {
if ($chatId) {
await stopTasksByChatId(localStorage.token, $chatId).catch((error) => {
toast.error(`${error}`);
return null;
});
} else {
for (const taskId of taskIds) {
const res = await stopTask(localStorage.token, taskId).catch((error) => {
toast.error(`${error}`);
return null;
});
}
}
taskIds = null;
const responseMessage = history.messages[history.currentId];
// Set all response messages to done
if (responseMessage.parentId && history.messages[responseMessage.parentId]) {
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
}
}
history.messages[history.currentId] = responseMessage;
if (autoScroll) {
scrollToBottom();
}
}
if (generating) {
generating = false;
generationController?.abort();
generationController = null;
}
if (processQueue) {
await processNextInQueue($chatId);
}
};
const submitMessage = async (parentId, prompt) => {
let userPrompt = prompt;
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: parentId,
childrenIds: [],
role: 'user',
content: userPrompt,
models: selectedModels,
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
};
if (parentId !== null) {
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
userMessageId
];
}
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
await tick();
if (autoScroll) {
scrollToBottom();
}
await sendMessage(history, userMessageId);
};
const regenerateResponse = async (message, suggestionPrompt = null) => {
console.log('regenerateResponse');
if (history.currentId) {
let userMessage = history.messages[message.parentId];
if (!userMessage) {
toast.error($i18n.t('Parent message not found'));
return;
}
if (autoScroll) {
scrollToBottom();
}
await sendMessage(history, userMessage.id, {
...(suggestionPrompt
? {
messages: createMessagesList(history, message.id),
regenerationPrompt: suggestionPrompt
}
: {}),
...((userMessage?.models ?? [...selectedModels]).length > 1
? {
// If multiple models are selected, use the model from the message
modelId: message.model,
modelIdx: message.modelIdx
}
: {})
});
}
};
const continueResponse = async () => {
console.log('continueResponse');
const _chatId = JSON.parse(JSON.stringify($chatId));
if (history.currentId && history.messages[history.currentId].done == true) {
const responseMessage = history.messages[history.currentId];
responseMessage.done = false;
await tick();
const model = $models
.filter((m) => m.id === (responseMessage?.selectedModelId ?? responseMessage.model))
.at(0);
if (model) {
await sendMessageSocket(
model,
createMessagesList(history, responseMessage.id),
history,
responseMessage.id,
_chatId,
{ continueResponse: true }
);
}
}
};
const mergeResponses = async (messageId, responses, _chatId) => {
console.log('mergeResponses', messageId, responses);
const message = history.messages[messageId];
const mergedResponse = {
status: true,
content: ''
};
message.merged = mergedResponse;
history.messages[messageId] = message;
try {
generating = true;
const [res, controller] = await generateMoACompletion(
localStorage.token,
message.model ?? '',
message.parentId ? history.messages[message.parentId].content : '',
responses
);
if (res && res.ok && res.body && generating) {
generationController = controller as AbortController;
const textStream = await createOpenAITextStream(
res.body,
Boolean($settings?.splitLargeChunks ?? false)
);
for await (const update of textStream) {
const { value, done, sources, error, usage } = update;
if (error || done) {
generating = false;
generationController = null;
break;
}
if (mergedResponse.content == '' && value == '\n') {
continue;
} else {
mergedResponse.content += value;
history.messages[messageId] = message;
}
if (autoScroll) {
scheduleScrollToBottom();
}
}
await saveChatHandler(_chatId, history);
} else {
console.error(res);
}
} catch (e) {
console.error(e);
}
};
const initChatHandler = async (history) => {
let _chatId = $chatId;
if (!$temporaryChatEnabled) {
chat = await createNewChat(
localStorage.token,
{
id: _chatId,
title: $i18n.t('New Chat'),
models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
messages: createMessagesList(history, history.currentId),
tags: [],
timestamp: Date.now()
},
$selectedFolder?.id
);
_chatId = chat.id;
await chatId.set(_chatId);
if (!embedded) {
window.history.replaceState(history.state, '', `/c/${_chatId}`);
}
await tick();
if (!embedded) {
await refreshChatList(localStorage.token);
}
selectedFolder.set(null);
} else {
_chatId = `local:${$socket?.id}`; // Use socket id for temporary chat
await chatId.set(_chatId);
}
await tick();
return _chatId;
};
const saveChatHandler = async (_chatId, history) => {
if ($chatId == _chatId) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
models: selectedModels,
history: history,
messages: createMessagesList(history, history.currentId),
params: params,
files: chatFiles
});
}
}
};
const saveControls = async () => {
if (!$chatId || $temporaryChatEnabled) return;
const loaded = chat?.chat ?? {};
if (equal(params, loaded.params ?? {}) && equal(chatFiles, loaded.files ?? [])) return;
const res = await updateChatById(localStorage.token, $chatId, {
params,
files: chatFiles
}).catch((err) => {
console.error('[controls autosave]', err);
return null;
});
// Refresh the dedupe baseline so a later revert still saves.
if (res) chat = res;
};
const MAX_DRAFT_LENGTH = 5000;
let saveDraftTimeout: ReturnType<typeof setTimeout> | null = null;
const saveDraft = async (draft: any, chatId: string | null = null) => {
if (saveDraftTimeout) {
clearTimeout(saveDraftTimeout);
}
if (draft.prompt !== null && draft.prompt.length < MAX_DRAFT_LENGTH) {
saveDraftTimeout = setTimeout(async () => {
await sessionStorage.setItem(
`chat-input${chatId ? `-${chatId}` : ''}`,
JSON.stringify(draft)
);
}, 500);
} else {
sessionStorage.removeItem(`chat-input${chatId ? `-${chatId}` : ''}`);
}
};
const clearDraft = async (chatId: string | null = null) => {
if (saveDraftTimeout) {
clearTimeout(saveDraftTimeout);
}
await sessionStorage.removeItem(`chat-input${chatId ? `-${chatId}` : ''}`);
};
const moveChatHandler = async (chatId, folderId) => {
if (chatId && folderId) {
const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat moved successfully'));
}
} else {
toast.error($i18n.t('Failed to move chat'));
}
};
const archiveChatHandler = async (id: string) => {
try {
await archiveChatById(localStorage.token, id);
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
}
};
let showDeleteConfirm = false;
const confirmWebSearch = async () => {
const userPrompt = pendingWebSearchPrompt;
pendingWebSearchPrompt = null;
webSearchConfirmed = true;
if (userPrompt !== null) {
await submitHandler(userPrompt);
} else {
webSearchEnabled = true;
}
};
const deleteChatHandler = async (id: string) => {
showDeleteConfirm = true;
};
const confirmDeleteChat = async () => {
const id = $chatId;
if (!id) return;
try {
const res = await deleteChatById(localStorage.token, id);
if (res) {
initNewChat();
await goto('/');
await refreshChatList(localStorage.token, { refreshPinned: true });
allTags.set(await getAllTags(localStorage.token));
toast.success($i18n.t('Chat deleted.'));
}
} catch (error) {
console.error('Error deleting chat:', error);
toast.error(`${error}`);
}
};
</script>
<svelte:head>
<title>
{$settings.showChatTitleInTab !== false && $chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>
<audio id="audioElement" style="display: none;"></audio>
<WebSearchConfirmDialog
bind:show={showWebSearchConfirm}
title={$i18n.t('Use Web Search?')}
message={($config?.features?.web_search_confirmation_content ?? '').trim() !== ''
? ($config?.features?.web_search_confirmation_content ?? '')
: $i18n.t('Your query will be sent to the configured web search provider.')}
confirmLabel={$i18n.t('Continue')}
cancelLabel={$i18n.t('Cancel')}
on:confirm={confirmWebSearch}
on:cancel={() => {
if (pendingWebSearchPrompt === null) {
webSearchEnabled = false;
}
pendingWebSearchPrompt = null;
}}
/>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
confirmDeleteChat();
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-normal">{$chatTitle}</span>.
</div>
</DeleteConfirmDialog>
<EventConfirmDialog
bind:show={showEventConfirmation}
title={eventConfirmationTitle}
message={eventConfirmationMessage}
input={eventConfirmationInput}
inputPlaceholder={eventConfirmationInputPlaceholder}
inputValue={eventConfirmationInputValue}
inputType={eventConfirmationInputType}
inputOptions={eventConfirmationInputOptions}
on:confirm={(e) => {
if (eventConfirmationInput) {
eventCallback(e.detail);
} else if (e.detail) {
eventCallback(e.detail);
} else {
eventCallback(true);
}
}}
on:cancel={() => {
eventCallback(false);
}}
/>
<div
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))]'
: ' '} w-full max-w-full flex flex-col"
id={chatContainerId}
>
{#if !loading}
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
{#if !embedded && $selectedFolder && $selectedFolder?.meta?.background_image_url}
<div
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}) "
/>
<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"
/>
{:else if !embedded && ($settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null)}
<div
class="absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$settings?.backgroundImageUrl ??
$config?.license_metadata?.background_image_url}) "
/>
<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"
/>
{/if}
<PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
<FilesOverlay show={dragged} />
{#if embedded}
<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="flex min-w-0 items-center gap-2">
<EmbeddedChatHistoryDropdown
title={embeddedHeaderTitle}
chats={embeddedChats}
canCreateNew={!!onNewEmbeddedChat &&
Object.keys(history?.messages ?? {}).length > 0}
{loading}
onNewChat={onNewEmbeddedChat}
onSelectChat={onSelectEmbeddedChat}
onDeleteChat={onDeleteEmbeddedChat}
/>
</div>
<Tooltip content={$i18n.t('Close')} placement="bottom">
<button
type="button"
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')}
>
<XMark className="size-4" strokeWidth="2" />
</button>
</Tooltip>
</div>
{:else}
<Navbar
bind:this={navbarElement}
{readOnly}
chat={{
id: $chatId,
chat: {
title: $chatTitle,
models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
timestamp: Date.now()
}
}}
{history}
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(
localStorage.token,
{
id: uuidv4(),
title: title.length > 50 ? `${title.slice(0, 50)}...` : title,
models: selectedModels,
params: params,
history: history,
messages: messages,
timestamp: Date.now()
},
null
);
if (savedChat) {
temporaryChatEnabled.set(false);
chatId.set(savedChat.id);
await refreshChatList(localStorage.token);
await goto(`/c/${savedChat.id}`);
toast.success($i18n.t('Conversation saved successfully'));
}
} catch (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
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={(e) => {
autoScroll =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 5;
isNearTop = messagesContainerElement.scrollTop <= 100;
}}
>
<div class=" h-full w-full flex flex-col">
<Messages
bind:this={messagesRef}
chatId={$chatId}
{readOnly}
bind:history
bind:autoScroll
bind:prompt
setInputText={(text) => {
messageInput?.setText(text);
}}
bind:selectedModels
{atSelectedModel}
className={embedded ? 'h-full flex pt-4' : 'h-full flex pt-18'}
{sendMessage}
{showMessage}
{submitMessage}
{continueResponse}
{regenerateResponse}
{mergeResponses}
{chatActionHandler}
{addMessages}
topPadding={!embedded}
bottomPadding={files.length > 0}
{onSelect}
{onInsertToNote}
/>
</div>
</div>
{#if readOnly}
<div class="pb-6 z-10">
<div class="text-xs text-gray-400 dark:text-gray-500 text-center">
{$i18n.t('Read only')}
</div>
</div>
{:else}
<div class=" pb-2 {dragged ? 'z-0' : '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}
onQueueSendNow={async (id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
if (item) {
// Remove from queue
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
await stopResponse(false);
await tick();
await submitPrompt(item.prompt, item.files);
}
}}
onQueueEdit={(id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
if (item) {
// Remove from queue
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
// Set files and restore prompt to input
files = item.files;
messageInput?.setText(item.prompt);
}
}}
onQueueDelete={(id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
chatRequestQueues.update((q) => ({
...q,
[$chatId]: queue.filter((m) => m.id !== id)
}));
}}
onChange={(data) => {
if (!$temporaryChatEnabled) {
saveDraft(data, $chatId);
}
}}
onWebSearchToggle={handleWebSearchToggle}
on:submit={async (e) => {
clearDraft($chatId);
if (e.detail || files.length > 0) {
await tick();
submitHandler(withSelectedText(e.detail));
}
}}
/>
<div
class="absolute bottom-1 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
>
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{/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-8">
<div class="w-full">
<div class="mb-2 text-[12px] text-gray-300 dark:text-gray-700">
{$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={async () => {
await tick();
await submitHandler(withSelectedText(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}
<div class="flex items-center h-full">
<Placeholder
{history}
bind:selectedModels
bind:messageInput
bind:files
bind:prompt
bind:autoScroll
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
bind:webSearchEnabled
bind:atSelectedModel
bind:showCommands
bind:dragged
{pendingOAuthTools}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
{onSelect}
{onUpload}
onWebSearchToggle={handleWebSearchToggle}
onChange={(data) => {
if (!$temporaryChatEnabled) {
saveDraft(data);
}
}}
on:submit={async (e) => {
clearDraft();
if (e.detail || files.length > 0) {
await tick();
submitHandler(withSelectedText(e.detail));
}
}}
/>
</div>
{/if}
</div>
</Pane>
{#if !embedded}
<ChatControls
bind:this={controlPaneComponent}
bind:history
bind:chatFiles
bind:params
bind:files
bind:pane={controlPane}
chatId={$chatId}
modelId={selectedModelIds?.at(0) ?? null}
models={selectedModelIds.reduce((a, e, i, arr) => {
const model = $models.find((m) => m.id === e);
if (model) {
return [...a, model];
}
return a;
}, [])}
submitPrompt={submitHandler}
{stopResponse}
{showMessage}
{eventTarget}
{codeInterpreterEnabled}
containerId={chatContainerId}
/>
{/if}
</PaneGroup>
</div>
{:else if loading}
<div class=" flex items-center justify-center h-full w-full">
<div class="m-auto">
<Spinner className="size-5" />
</div>
</div>
{/if}
</div>
<style>
::-webkit-scrollbar {
height: 0.5rem;
width: 0.5rem;
}
</style>