From 48024574c85912b065001048ae058da9f4ecef9f Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:08:02 +0800 Subject: [PATCH] feat(chatui): refine settings, composer and streaming experience (#10021) * feat(chatui): refine settings, composer and streaming experience * style(chatui): compact composer and narrow message layouts * style(chatui): align composer gutters and remove excess height * style(chatui): give composer editing area more height * fix(chatui): preserve reading width when side panels open --- dashboard/src/components/chat/Chat.vue | 637 +++++++++--------- dashboard/src/components/chat/ChatInput.vue | 584 ++++------------ .../src/components/chat/ChatMessageList.vue | 512 +++++++------- .../components/chat/ChatSettingsDialog.vue | 358 ++++++++++ .../chat/MessageContentTransition.vue | 56 ++ dashboard/src/components/chat/MessageList.vue | 44 +- .../src/components/chat/ReasoningSidebar.vue | 25 +- .../src/components/chat/RegenerateMenu.vue | 45 +- .../src/components/chat/StandaloneChat.vue | 42 +- .../src/components/chat/ThinkingIndicator.vue | 73 ++ dashboard/src/components/chat/ThreadPanel.vue | 64 +- .../chat/ThreadedMarkdownMessagePart.vue | 10 +- .../components/chat/WorkspaceFilesPanel.vue | 33 +- .../src/components/chat/chatMarkdown.css | 56 ++ .../components/chat/chatPanelTransition.css | 33 + .../components/chat/markdownRenderConfig.ts | 23 + .../MarkdownMessagePart.vue | 7 +- .../message_list_comps/ReasoningBlock.vue | 56 +- .../message_list_comps/ReasoningTimeline.vue | 22 +- .../chat/message_list_comps/RefsSidebar.vue | 23 +- .../src/i18n/locales/en-US/features/chat.json | 27 +- .../src/i18n/locales/ja-JP/features/chat.json | 27 +- .../src/i18n/locales/ru-RU/features/chat.json | 27 +- .../src/i18n/locales/zh-CN/features/chat.json | 27 +- 24 files changed, 1623 insertions(+), 1188 deletions(-) create mode 100644 dashboard/src/components/chat/ChatSettingsDialog.vue create mode 100644 dashboard/src/components/chat/MessageContentTransition.vue create mode 100644 dashboard/src/components/chat/ThinkingIndicator.vue create mode 100644 dashboard/src/components/chat/chatMarkdown.css create mode 100644 dashboard/src/components/chat/chatPanelTransition.css diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index d9348393a..60f37fdf9 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -21,7 +21,10 @@ class="chat-sidebar-brand" :class="{ collapsed: isSidebarCollapsed }" > -
+
@@ -88,16 +87,10 @@ > - + {{ tm("actions.newChat") }} -
+ +
-
+
mdi-cloud-upload {{ tm("input.dropToUpload") }} @@ -363,8 +238,6 @@ :staged-audio-url="stagedAudioUrl" :staged-files="stagedNonImageFiles" :disabled="sending" - :enable-streaming="enableStreaming" - :enable-reasoning="enableReasoning" :is-recording="isRecording" :is-running=" Boolean(currSessionId && isSessionRunning(currSessionId)) @@ -378,8 +251,6 @@ :placeholder="tm('input.projectPlaceholder')" @send="sendCurrentMessage" @stop="stopCurrentSession" - @toggle-streaming="toggleStreaming" - @toggle-reasoning="enableReasoning = !enableReasoning" @remove-image="removeImage" @remove-audio="removeAudio" @remove-file="removeFile" @@ -400,7 +271,13 @@
@@ -412,6 +289,7 @@
+ (), { - chatboxMode: false, - active: true, -}); +const props = withDefaults( + defineProps<{ chatboxMode?: boolean; active?: boolean }>(), + { + chatboxMode: false, + active: true, + }, +); const route = useRoute(); const router = useRouter(); @@ -648,8 +537,6 @@ const { t } = useI18n(); const { tm } = useModuleI18n("features/chat"); const confirmDialog = useConfirmDialog(); const toast = useToast(); -const { languageOptions, currentLanguage, switchLanguage, locale } = - useLanguageSwitcher(); const { sessions, currSessionId, @@ -719,9 +606,14 @@ const tokenProviderConfigs = ref([]); const tokenModelMetadata = ref>({}); const selectedTokenProviderId = ref(""); const messagesContainer = ref(null); +const messagesContent = ref(null); const composerShell = ref(null); const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); +const isAwayFromBottom = ref(false); +let lastMessagesScrollTop = 0; +let touchScrollY = 0; +let scrollIntent = 0; const replyTarget = ref(null); const threadPanelOpen = ref(false); const activeThread = ref(null); @@ -746,10 +638,11 @@ const threadSelection = reactive<{ message: null, selectedText: "", }); +const settingsOpen = ref(false); const enableStreaming = ref(true); const enableReasoning = ref(true); const sendShortcut = ref<"enter" | "shift_enter">("enter"); -let composerResizeObserver: ResizeObserver | null = null; +let chatResizeObserver: ResizeObserver | null = null; const { isRecording, startRecording: startRecorder, @@ -825,27 +718,6 @@ const transportMode = ref( : "sse", ); -const pointerMediaQuery = window.matchMedia('(pointer: coarse)'); -const isTouchDevice = ref(pointerMediaQuery.matches); -const handlePointerChange = (e: MediaQueryListEvent) => { - isTouchDevice.value = e.matches; -}; -pointerMediaQuery.addEventListener('change', handlePointerChange); -onBeforeUnmount(() => { - pointerMediaQuery.removeEventListener('change', handlePointerChange); -}); - -const transportOptions: Array<{ value: TransportMode; labelKey: string }> = [ - { value: "sse", labelKey: "transport.sse" }, - { value: "websocket", labelKey: "transport.websocket" }, -]; -const currentTransportLabel = computed(() => - tm( - transportOptions.find((item) => item.value === transportMode.value) - ?.labelKey || "transport.sse", - ), -); - watch(transportMode, (mode) => { localStorage.setItem("chat.transportMode", mode); }); @@ -942,7 +814,10 @@ const latestContextTokens = computed(() => { }); const tokenUsageIndicator = computed(() => { const used = latestContextTokens.value; - const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value); + const limit = contextLimit( + currentTokenProvider.value, + currentTokenMetadata.value, + ); if (used <= 0 || limit <= 0) return null; const percent = (used / limit) * 100; @@ -1004,15 +879,26 @@ watch( onMounted(async () => { if (typeof ResizeObserver !== "undefined") { - composerResizeObserver = new ResizeObserver(([entry]) => { + chatResizeObserver = new ResizeObserver((entries) => { const container = messagesContainer.value; - if (!entry || !container) return; - const height = Math.ceil(entry.target.getBoundingClientRect().height); - container.style.setProperty("--chat-composer-height", `${height}px`); + if (!container) return; + for (const entry of entries) { + if (entry.target === composerShell.value) { + const height = Math.ceil(entry.target.getBoundingClientRect().height); + container.style.setProperty("--chat-composer-height", `${height}px`); + } + } + isAwayFromBottom.value = + container.scrollHeight - container.scrollTop - container.clientHeight > + 2; if (shouldStickToBottom.value) scrollToBottom(); }); - if (composerShell.value) { - composerResizeObserver.observe(composerShell.value); + for (const element of [ + composerShell.value, + messagesContent.value, + messagesContainer.value, + ]) { + if (element) chatResizeObserver.observe(element); } } @@ -1031,16 +917,24 @@ onMounted(async () => { }); onBeforeUnmount(() => { - composerResizeObserver?.disconnect(); + chatResizeObserver?.disconnect(); chatHeader.CLEAR_CONTEXT(); cleanupMediaCache(); }); -watch(composerShell, (element, previousElement) => { - if (!composerResizeObserver) return; - if (previousElement) composerResizeObserver.unobserve(previousElement); - if (element) composerResizeObserver.observe(element); -}); +watch( + [composerShell, messagesContent, messagesContainer], + (elements, previousElements) => { + if (!chatResizeObserver) return; + for (const element of previousElements) { + if (element) chatResizeObserver.unobserve(element); + } + for (const element of elements) { + if (element) chatResizeObserver.observe(element); + } + }, + { flush: "post" }, +); watch( () => route.params.conversationId, @@ -1113,7 +1007,8 @@ function sessionTitle(session: Session) { function syncSelectedTokenProvider() { if (typeof window === "undefined") return; - selectedTokenProviderId.value = localStorage.getItem("selectedProvider") || ""; + selectedTokenProviderId.value = + localStorage.getItem("selectedProvider") || ""; } async function loadTokenProviders() { @@ -1121,9 +1016,8 @@ async function loadTokenProviders() { try { const response = await providerApi.listByProviderType("chat_completion"); if (response.data.status === "ok") { - tokenModelMetadata.value = ( - (response.data as any).model_metadata || {} - ) as Record; + tokenModelMetadata.value = ((response.data as any).model_metadata || + {}) as Record; tokenProviderConfigs.value = ( (response.data.data || []) as unknown as TokenProviderConfig[] ).filter((provider) => provider.enable !== false); @@ -1195,7 +1089,10 @@ async function loadProjectSessions(projectId = selectedProjectId.value) { async function handleProjectToggle(projectId: string, expanded: boolean) { if (!expanded || projectSessionsById.value[projectId]) return; if (loadingProjectSessionIds.value.includes(projectId)) return; - loadingProjectSessionIds.value = [...loadingProjectSessionIds.value, projectId]; + loadingProjectSessionIds.value = [ + ...loadingProjectSessionIds.value, + projectId, + ]; try { await loadProjectSessions(projectId); } finally { @@ -1352,7 +1249,7 @@ async function selectSession(sessionId: string, pushRoute = true) { if (!loadedSessions[sessionId]) { await loadSessionMessages(sessionId); } - scrollToBottom(); + scrollToBottom(true); closeMobileSidebar(); await focusChatInput(); } @@ -1397,7 +1294,7 @@ async function sendCurrentMessage() { draft.value = ""; replyTarget.value = null; clearStaged({ revokeUrls: false }); - scrollToBottom(); + scrollToBottom(true); sendMessageStream({ sessionId, @@ -1494,6 +1391,7 @@ function scrollToMessage(messageId?: string | number) { (message) => String(message.id) === String(messageId), ); if (index < 0) return; + shouldStickToBottom.value = false; const rows = messagesContainer.value?.querySelectorAll(".message-row"); rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" }); } @@ -1531,7 +1429,7 @@ async function saveMessageEdit() { selectedProvider: selection?.providerId || "", selectedModel: selection?.modelName || "", }); - scrollToBottom(); + scrollToBottom(true); } else if (result.needsRegenerate) { const index = activeMessages.value.findIndex( (message) => String(message.id) === String(target.id), @@ -1600,7 +1498,8 @@ function handleBotTextSelection(event: MouseEvent, message: ChatRecord) { async function createThreadFromSelection() { const message = threadSelection.message; - if (!currSessionId.value || !message?.id || !threadSelection.selectedText) return; + if (!currSessionId.value || !message?.id || !threadSelection.selectedText) + return; try { const response = await chatApi.createThread({ session_id: currSessionId.value, @@ -1669,7 +1568,8 @@ function openReasoningPanel(payload: { async function deleteThread(thread: ChatThread) { if (deletingThread.value) return; - if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return; + if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) + return; deletingThread.value = true; try { await chatApi.deleteThread(thread.thread_id); @@ -1705,10 +1605,6 @@ async function handleFilesSelected(files: FileList | File[]) { } } -function toggleStreaming() { - enableStreaming.value = !enableStreaming.value; -} - async function startRecording() { try { await startRecorder(); @@ -1731,21 +1627,81 @@ async function stopRecording() { } } +function handleMessagesInteraction( + event: WheelEvent | TouchEvent | PointerEvent | KeyboardEvent, +) { + if (event instanceof WheelEvent) { + if (event.ctrlKey || event.deltaY === 0) return; + scrollIntent = Math.sign(event.deltaY); + } else if (event.type === "touchstart" || event.type === "touchmove") { + const touch = (event as TouchEvent).touches[0]; + if (!touch) return; + if (event.type === "touchstart") { + touchScrollY = touch.clientY; + return; + } + scrollIntent = Math.sign(touchScrollY - touch.clientY); + touchScrollY = touch.clientY; + } else if (event instanceof KeyboardEvent) { + const target = event.target as HTMLElement; + if ( + target.closest("input, textarea, select, [contenteditable], button, a") + ) { + return; + } + if ( + ["ArrowUp", "PageUp", "Home"].includes(event.key) || + (event.key === " " && event.shiftKey) + ) { + scrollIntent = -1; + } else if (["ArrowDown", "PageDown", "End", " "].includes(event.key)) { + scrollIntent = 1; + } else { + return; + } + } else { + if (event.target !== messagesContainer.value) return; + scrollIntent = 0; + shouldStickToBottom.value = false; + } + if (scrollIntent < 0) shouldStickToBottom.value = false; +} + function handleMessagesScroll() { threadSelection.visible = false; const container = messagesContainer.value; if (!container) return; - const distance = - container.scrollHeight - container.scrollTop - container.clientHeight; - shouldStickToBottom.value = distance < 80; + const maxScrollTop = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); + const scrollTop = Math.max(0, container.scrollTop); + const previousTop = Math.min(lastMessagesScrollTop, maxScrollTop); + isAwayFromBottom.value = maxScrollTop - scrollTop > 2; + if (scrollTop < previousTop) { + shouldStickToBottom.value = false; + } else if ( + scrollTop > previousTop && + !isAwayFromBottom.value && + scrollIntent >= 0 + ) { + shouldStickToBottom.value = true; + } + lastMessagesScrollTop = scrollTop; } -function scrollToBottom() { +function scrollToBottom(resumeFollowing = false) { + if (resumeFollowing) { + shouldStickToBottom.value = true; + scrollIntent = 0; + } nextTick(() => { const container = messagesContainer.value; - if (!container) return; + // Recheck after rendering so queued stream updates cannot override user intent. + if (!container || !shouldStickToBottom.value) return; container.scrollTop = container.scrollHeight; - shouldStickToBottom.value = true; + lastMessagesScrollTop = Math.max(0, container.scrollTop); + isAwayFromBottom.value = false; }); } @@ -1764,10 +1720,6 @@ async function stopCurrentSession() { console.error("Failed to stop session:", error); } } - -function toggleTheme() { - customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark"); -} diff --git a/dashboard/src/components/chat/MessageContentTransition.vue b/dashboard/src/components/chat/MessageContentTransition.vue new file mode 100644 index 000000000..41b22ba81 --- /dev/null +++ b/dashboard/src/components/chat/MessageContentTransition.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index f7dfd7d42..e2621af0e 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -16,13 +16,7 @@ :class="isUserMessage(msg) ? 'from-user' : 'from-bot'" > - - +
@@ -30,11 +24,7 @@ class="message-bubble" :class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }" > -
- {{ tm("message.loading") }} -
- - - +
@@ -275,6 +269,7 @@ + + diff --git a/dashboard/src/components/chat/ThreadPanel.vue b/dashboard/src/components/chat/ThreadPanel.vue index f6b398408..149aed34d 100644 --- a/dashboard/src/components/chat/ThreadPanel.vue +++ b/dashboard/src/components/chat/ThreadPanel.vue @@ -1,6 +1,6 @@