This commit is contained in:
Timothy Jaeryang Baek
2026-04-13 21:29:03 -05:00
parent 33a4d1b412
commit cf4218e688
7 changed files with 462 additions and 190 deletions
+107 -125
View File
@@ -81,7 +81,6 @@
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import {
chatCompleted,
generateQueries,
chatAction,
generateMoACompletion,
@@ -491,6 +490,22 @@
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;
} else if (type === 'chat:message:favorite') {
// Update message favorite status
message.favorite = data.favorite;
@@ -1361,6 +1376,17 @@
taskIds = taskRes.task_ids;
}
// If no active tasks and current message is incomplete, generation was interrupted
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
if (
currentMessage &&
currentMessage.role === 'assistant' &&
!currentMessage.done &&
(!taskIds || taskIds.length === 0)
) {
currentMessage.done = true;
}
await tick();
return true;
@@ -1416,71 +1442,12 @@
};
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
if (!responseMessageId) {
console.error('chatCompleted: missing message id', {
chatId: _chatId,
modelId,
messageCount: messages?.length ?? 0
});
return;
// Backend handles outlet filters and persistence inline.
// Just refresh the sidebar chat list.
if ($chatId == _chatId && !$temporaryChatEnabled) {
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
const res = await chatCompleted(localStorage.token, {
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.usage ? { usage: m.usage } : {}),
...(m.sources ? { sources: m.sources } : {})
})),
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
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) {
if (message?.id) {
// Add null check for message and message.id
history.messages[message.id] = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
: {}),
...message
};
}
}
}
await tick();
if ($chatId == _chatId) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
models: selectedModels,
messages: messages,
history: history,
params: params,
files: chatFiles
});
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
}
taskIds = null;
};
@@ -1894,7 +1861,7 @@
saveSessionSelectedModels();
await sendMessage(history, userMessageId, { newChat: true });
await sendMessage(history, userMessageId);
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
@@ -1994,13 +1961,11 @@
{
messages = null,
modelId = null,
modelIdx = null,
newChat = false
modelIdx = null
}: {
messages?: any[] | null;
modelId?: string | null;
modelIdx?: number | null;
newChat?: boolean;
} = {}
) => {
if (autoScroll) {
@@ -2019,6 +1984,8 @@
: selectedModels;
// Create response messages for each selected model
// Build message_ids map: {model_id: assistant_message_id}
const messageIdsMap: Record<string, string> = {};
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
const model = $models.filter((m) => m.id === modelId).at(0);
@@ -2043,7 +2010,6 @@
// Append messageId to childrenIds of parent message
if (parentId !== null && history.messages[parentId]) {
// Add null check before accessing childrenIds
history.messages[parentId].childrenIds = [
...history.messages[parentId].childrenIds,
responseMessageId
@@ -2051,68 +2017,71 @@
}
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
messageIdsMap[modelId] = responseMessageId;
}
}
history = history;
// Create new chat if newChat is true and first user message
if (newChat && _history.messages[_history.currentId].parentId === null) {
_chatId = await initChatHandler(_history);
// New chat — backend generates the chat_id on first request
if (!_chatId) {
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);
// Save chat after all messages have been created
await saveChatHandler(_chatId, _history);
await Promise.all(
selectedModelIds.map(async (modelId, _modelIdx) => {
console.log('modelId', modelId);
const model = $models.filter((m) => m.id === modelId).at(0);
// 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 (model) {
// If there are image files, check if model is vision capable
// Skip this check if image generation is enabled, as images may be for editing or are generated outputs in the history
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
})
);
if (
hasImages &&
!(model.info?.meta?.capabilities?.vision ?? true) &&
!imageGenerationEnabled
) {
toast.error(
$i18n.t('Model {{modelName}} is not vision capable', {
modelName: model.name ?? model.id
})
);
}
let responseMessageId =
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
scrollToBottom();
await sendMessageSocket(
model,
messages && messages.length > 0
? messages
: createMessagesList(_history, responseMessageId),
_history,
responseMessageId,
_chatId
);
if (chatEventEmitter) clearInterval(chatEventEmitter);
} else {
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
}
})
);
}
}
// Single request — backend fans out to all models
const primaryModelId = selectedModelIds[0];
const primaryModel = $models.filter((m) => m.id === primaryModelId).at(0);
const primaryResponseMessageId = messageIdsMap[primaryModelId];
if (primaryModel && primaryResponseMessageId) {
const chatEventEmitter = await getChatEventEmitter(primaryModel.id, _chatId);
scrollToBottom();
await sendMessageSocket(
primaryModel,
messages && messages.length > 0
? messages
: createMessagesList(_history, primaryResponseMessageId),
_history,
primaryResponseMessageId,
_chatId,
selectedModelIds.length > 1 ? messageIdsMap : undefined
);
if (chatEventEmitter) clearInterval(chatEventEmitter);
}
};
const getFeatures = () => {
@@ -2167,7 +2136,7 @@
.map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`)));
};
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId, messageIdsMap?: Record<string, string>) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
@@ -2357,12 +2326,13 @@
model_item: $models.find((m) => m.id === model.id),
session_id: $socket?.id,
chat_id: $chatId,
chat_id: _chatId || undefined,
folder_id: $selectedFolder?.id ?? undefined,
id: responseMessageId,
parent_id: userMessage?.id ?? null,
parent_message: userMessage,
...(messageIdsMap ? { message_ids: messageIdsMap } : {}),
parent_id: userMessage?.parentId ?? null,
user_message: userMessage,
background_tasks: {
...(!$temporaryChatEnabled &&
@@ -2419,10 +2389,22 @@
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 (taskIds) {
taskIds.push(res.task_id);
taskIds.push(...newTaskIds);
} else {
taskIds = [res.task_id];
taskIds = newTaskIds;
}
// Backend returns chat_id for new chats — set store + URL
if (res.chat_id && $chatId !== res.chat_id) {
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
}
}
}
}
@@ -88,10 +88,21 @@
let mouseOver = false;
// Local state: tracks the last updatedAt seen while the user was viewing
// this chat. Survives prop refreshes from sidebar data re-fetches that
// would overwrite the `lastReadAt` prop with a stale server value.
let viewedAt: number | null = null;
$: if (id === $chatId) {
viewedAt = updatedAt ?? Date.now() / 1000;
}
$: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null;
$: unread =
id !== $chatId &&
!$activeChatIds.has(id) &&
(lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt));
(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
const loadChat = async () => {
if (!chat) {