+
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 @@
-
-