This commit is contained in:
Timothy Jaeryang Baek 2026-10-29 17:06:35 +09:00
parent 722f71f2cc
commit 193cf225f3
7 changed files with 105 additions and 78 deletions

View file

@ -753,10 +753,11 @@ export const getChatListByTagName = async (token: string = '', tagName: string)
}));
};
export const getChatById = async (token: string, id: string) => {
export const getChatById = async (token: string, id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -772,6 +773,7 @@ export const getChatById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
if (signal?.aborted) return null;
error = err.detail;
console.error(err);
@ -1465,10 +1467,11 @@ export const deleteChatById = async (token: string, id: string) => {
return res;
};
export const getTagsById = async (token: string, id: string) => {
export const getTagsById = async (token: string, id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/tags`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -1484,6 +1487,7 @@ export const getTagsById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
if (signal?.aborted) return null;
error = getErrorDetail(err);
console.error(err);

View file

@ -361,10 +361,11 @@ export const stopTasksByChatId = async (token: string, chat_id: string) => {
return res;
};
export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
export const getTaskIdsByChatId = async (token: string, chat_id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -377,6 +378,7 @@ export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
return res.json();
})
.catch((err) => {
if (signal?.aborted) return null;
console.error(err);
if ('detail' in err) {
error = err.detail;

View file

@ -899,12 +899,20 @@
}
let saveControlsTimer;
$: if (!loading && !$temporaryChatEnabled && $chatId && chatFiles && selectedModels) {
$: if (!loading && !$temporaryChatEnabled && $chatId && chatFiles) {
clearTimeout(saveControlsTimer);
saveControlsTimer = setTimeout(saveControls, 400);
}
let chatLoadController = new AbortController();
const startChatLoad = () => {
chatLoadController.abort();
chatLoadController = new AbortController();
return chatLoadController.signal;
};
onDestroy(() => chatLoadController.abort());
const navigateHandler = async () => {
const signal = startChatLoad();
modelDefaults = null;
cancelBrowserInteractions();
bridge?.end();
@ -918,6 +926,7 @@
clearTimeout(saveControlsTimer);
await saveControls();
if (signal.aborted) return;
loading = true;
prompt = '';
@ -931,47 +940,17 @@
imageGenerationEnabled = false;
codeInterpreterEnabled = 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');
if (!$page.url.searchParams.has('message')) 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);
}
await setDefaults(await restoreChatInput(storageChatInput));
messageInput?.focus({ preventScroll: true });
if (chatIdProp) {
await loadChat(signal);
} else if (!embedded) {
await goto('/');
} else {
loading = false;
console.warn('[note-chat] embedded load failed; clearing spinner', {
chatIdProp,
activeChatId: $chatId
});
}
};
const initEmbeddedDraft = async () => {
chatLoadController.abort();
modelDefaults = null;
terminalContext = null;
clearTimeout(saveControlsTimer);
@ -1435,14 +1414,15 @@
if (!id || $temporaryChatEnabled) return Promise.resolve();
if (chatRefresh?.id === id) return chatRefresh.promise;
const baseline = { ...history.messages };
const signal = chatLoadController.signal;
const promise = (async () => {
try {
const fresh = await getChatById(localStorage.token, id);
if ($chatId !== id || joinedChatId !== id) return;
const fresh = await getChatById(localStorage.token, id, signal);
if ($chatId !== id || joinedChatId !== id || signal.aborted) return;
chat = fresh;
mergeChatMessages(fresh.chat.history, baseline);
} catch (error) {
if ($chatId === id && joinedChatId === id) {
if ($chatId === id && joinedChatId === id && !signal.aborted) {
clearPresence();
chat = null;
history = { messages: {}, currentId: null };
@ -2639,6 +2619,7 @@
};
const initNewChat = async () => {
chatLoadController.abort();
modelDefaults = null;
clearTimeout(saveControlsTimer);
await saveControls();
@ -2893,7 +2874,7 @@
messageInput?.focus({ preventScroll: true });
};
const loadChat = async () => {
const loadChat = async (signal = startChatLoad()) => {
if (chatIdProp && chatIdProp !== $chatId) {
terminalContext = null;
history = { messages: {}, currentId: null };
@ -2909,26 +2890,25 @@
temporaryChatEnabled.set(false);
}
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
const id = $chatId;
const loadedChat = await getChatById(localStorage.token, id, signal).catch(async (error) => {
console.error('[note-chat] getChatById failed', {
chatIdProp,
activeChatId: $chatId,
error
});
if (!embedded) {
await goto('/');
}
return null;
});
if (signal.aborted) return;
noteChatDebug('getChatById completed', {
found: !!chat,
chatId: chat?.id,
hasChatPayload: !!chat?.chat,
title: chat?.title
found: !!loadedChat,
chatId: loadedChat?.id,
hasChatPayload: !!loadedChat?.chat,
title: loadedChat?.title
});
if (chat) {
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
if (loadedChat) {
const loadedTags = await getTagsById(localStorage.token, id, signal).catch(async (error) => {
console.warn('[note-chat] getTagsById failed; continuing without tags', {
chatIdProp,
activeChatId: $chatId,
@ -2936,6 +2916,9 @@
});
return [];
});
if (signal.aborted) return;
chat = loadedChat;
tags = loadedTags;
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
const chatContent = chat.chat;
@ -3000,6 +2983,7 @@
autoScroll = true;
await tick();
if (signal.aborted) return;
// Mark all non-current assistant messages as done
if (history.currentId) {
@ -3020,7 +3004,7 @@
// 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)
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, id, signal)
.then((res) => res?.task_ids ?? [])
.catch((error) => {
console.warn('[note-chat] getTaskIdsByChatId failed; continuing without tasks', {
@ -3030,31 +3014,30 @@
});
return [];
});
if (signal.aborted) 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 (pendingTaskIds.length === 0) {
for (const message of Object.values(history.messages)) {
if (
message?.role === 'assistant' &&
(message.user_id ?? chat?.user_id) === $user?.id &&
!message.done &&
!messageHasPendingAskUser(message)
) {
message.done = true;
history = history;
if (taskIds === activeTaskIds) {
if (pendingTaskIds.length > 0 && !responseComplete) {
taskIds = pendingTaskIds;
} else {
taskIds = null;
// No active tasks and message incomplete → generation was interrupted
if (pendingTaskIds.length === 0) {
for (const message of Object.values(history.messages)) {
if (
message?.role === 'assistant' &&
(message.user_id ?? chat?.user_id) === $user?.id &&
!message.done &&
!messageHasPendingAskUser(message)
) {
message.done = true;
history = history;
}
}
}
}
@ -3062,6 +3045,27 @@
await tick();
if (signal.aborted) return;
if (loading) {
if (chatIdProp && !$temporaryChatEnabled) updateLastReadAt(chatIdProp);
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
if (!lastMessage || lastMessage.role !== 'assistant' || lastMessage.done) {
await processNextInQueue(chatIdProp);
}
if (signal.aborted) return;
const restored = await restoreChatInput(
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
if (signal.aborted) return;
await setDefaults(restored);
if (signal.aborted) return;
loading = false;
await tick();
if (signal.aborted) return;
if (!$page.url.searchParams.has('message')) scrollToBottom();
messageInput?.focus({ preventScroll: true });
}
return true;
} else {
console.warn('[note-chat] chat response missing chat payload', {
@ -3069,13 +3073,16 @@
activeChatId: $chatId,
chat
});
return null;
}
}
console.warn('[note-chat] no chat returned from getChatById', {
chatIdProp,
activeChatId: $chatId
});
chat = null;
if (!embedded) await goto('/');
else loading = false;
return false;
};
const scrollToBottom = async (behavior = 'auto') => {
@ -3769,6 +3776,7 @@
atSelectedModel = undefined;
selectedModels = [model.id];
saveControls(selectedModels);
saveSessionSelectedModels();
toast.success(`Model switched to: ${model.id}`);
messageInput?.setText('');
@ -4887,16 +4895,17 @@
});
};
const saveControls = async () => {
const saveControls = async (models: string[] | null = null) => {
if (isShared) return;
if (!$chatId || $temporaryChatEnabled || chat?.id !== $chatId) return;
const loaded = chat?.chat ?? {};
if (equal(chatFiles, loaded.files ?? []) && equal(selectedModels, loaded.models ?? [])) return;
// Model choices are passed explicitly; automatic fallbacks must not be saved.
if (!models?.length || !models.every(Boolean)) models = null;
if (!models && equal(chatFiles, chat.chat?.files ?? [])) return;
const id = $chatId;
const res = await updateChatById(localStorage.token, id, {
files: chatFiles,
...(selectedModels.length && selectedModels.every(Boolean) ? { models: selectedModels } : {})
...(models ? { models } : {})
}).catch((err) => {
console.error('[controls autosave]', err);
return null;
@ -5407,6 +5416,7 @@
{history}
{taskIds}
bind:selectedModels
onModelSelect={saveControls}
bind:files
bind:prompt
bind:autoScroll
@ -5504,6 +5514,7 @@
{history}
{taskIds}
bind:selectedModels
onModelSelect={saveControls}
bind:files
bind:prompt
bind:autoScroll
@ -5568,6 +5579,7 @@
bind:selectedModelIdx
{history}
bind:selectedModels
onModelSelect={saveControls}
bind:messageInput
bind:files
bind:prompt

View file

@ -166,6 +166,7 @@
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
export let onModelSelect: (models: string[]) => void = () => {};
let selectedModelIds = [];
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
@ -2791,6 +2792,7 @@
<ModelSelector
bind:this={modelSelector}
bind:selectedModels
{onModelSelect}
showSetDefault={!history?.currentId}
placement="auto"
align="end"

View file

@ -10,6 +10,7 @@
const i18n = getContext('i18n');
export let selectedModels = [''];
export let onModelSelect: (models: string[]) => void = () => {};
export let disabled = false;
export let showSetDefault = true;
@ -85,6 +86,7 @@
{disabled}
bind:compareEnabled={compareModels}
bind:values={selectedModels}
{onModelSelect}
/>
</div>
</div>

View file

@ -11,7 +11,7 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { onMount, getContext, tick } from 'svelte';
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { deleteModelById } from '$lib/apis/models';
@ -55,11 +55,11 @@
import ModelItem from './ModelItem.svelte';
const i18n: any = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = '';
export let value: string | null = '';
export let values: string[] | null = null;
export let onModelSelect: (models: string[]) => void = () => {};
export let compareEnabled = false;
export let multipleEnabled = false;
export let disabled = false;
@ -417,6 +417,7 @@
if (!enabled && values) {
values = [primaryValue || selectedValues[0] || ''];
value = values[0];
onModelSelect(values);
}
};
@ -433,11 +434,13 @@
values = nextValues.length ? nextValues : [item.value];
value = values[0];
onModelSelect(values);
return;
}
values = [item.value];
value = item.value;
onModelSelect(values);
show = false;
window.setTimeout(focusChatInput, 0);
return;

View file

@ -46,6 +46,7 @@
export let atSelectedModel: Model | undefined;
export let selectedModels: [''];
export let onModelSelect: (models: string[]) => void = () => {};
export let history;
export let chatId = '';
@ -262,6 +263,7 @@
bind:this={messageInput}
{history}
bind:selectedModels
{onModelSelect}
bind:files
bind:prompt
bind:autoScroll