From 193cf225f377ccc021b5128b80e5370e93895b2f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 29 Oct 2026 17:06:35 +0900 Subject: [PATCH] refac --- src/lib/apis/chats/index.ts | 8 +- src/lib/apis/index.ts | 4 +- src/lib/components/chat/Chat.svelte | 158 ++++++++++-------- src/lib/components/chat/MessageInput.svelte | 2 + src/lib/components/chat/ModelSelector.svelte | 2 + .../chat/ModelSelector/Selector.svelte | 7 +- src/lib/components/chat/Placeholder.svelte | 2 + 7 files changed, 105 insertions(+), 78 deletions(-) diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 3218e455e6..822b6e91e5 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -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); diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index c372a520a3..4f73c38bfe 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -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; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index ba296f72c1..438c173eb9 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -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 diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index e48b3ac29e..bafec0644a 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -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 @@ void = () => {}; export let disabled = false; export let showSetDefault = true; @@ -85,6 +86,7 @@ {disabled} bind:compareEnabled={compareModels} bind:values={selectedModels} + {onModelSelect} /> diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 2c0a0be114..45177075b5 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -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; diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index a520261eb0..3004665607 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -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