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
This commit is contained in:
Soulter
2026-09-10 22:08:02 +08:00
committed by GitHub
parent 6fb50cae91
commit 48024574c8
24 changed files with 1623 additions and 1188 deletions
+311 -326
View File
@@ -21,7 +21,10 @@
class="chat-sidebar-brand"
:class="{ collapsed: isSidebarCollapsed }"
>
<div v-if="!isSidebarCollapsed" class="chat-sidebar-brand-title Outfit">
<div
v-if="!isSidebarCollapsed"
class="chat-sidebar-brand-title Outfit"
>
<ChatUILogo class="chat-sidebar-brand-logo" />
<span class="chat-sidebar-brand-copy">
<span class="chat-sidebar-brand-name">AstrBot</span>
@@ -36,11 +39,10 @@
@click.stop="toggleChatSidebar"
>
<span class="chat-sidebar-rail-icon-stack">
<ChatUILogo class="chat-sidebar-brand-logo chat-sidebar-brand-logo--collapsed" />
<PanelLeft
:size="20"
class="sidebar-panel-toggle-icon"
<ChatUILogo
class="chat-sidebar-brand-logo chat-sidebar-brand-logo--collapsed"
/>
<PanelLeft :size="20" class="sidebar-panel-toggle-icon" />
</span>
</button>
<v-btn
@@ -51,10 +53,7 @@
variant="text"
@click.stop="toggleChatSidebar"
>
<PanelLeft
:size="20"
class="sidebar-panel-toggle-icon"
/>
<PanelLeft :size="20" class="sidebar-panel-toggle-icon" />
</v-btn>
</div>
@@ -88,16 +87,10 @@
>
<SquarePen :size="18" class="sidebar-action-icon" />
</button>
<v-btn
v-else
class="new-chat-btn"
variant="text"
@click="startNewChat"
>
<v-btn v-else class="new-chat-btn" variant="text" @click="startNewChat">
<SquarePen :size="18" class="sidebar-action-icon mr-2" />
<span>{{ tm("actions.newChat") }}</span>
</v-btn>
</div>
<div v-if="!isSidebarCollapsed" class="sidebar-content">
@@ -127,7 +120,9 @@
:key="session.session_id"
class="session-item"
:class="{
active: !isProviderWorkspace && currSessionId === session.session_id,
active:
!isProviderWorkspace && currSessionId === session.session_id,
running: isSessionRunning(session.session_id),
}"
role="button"
tabindex="0"
@@ -170,170 +165,50 @@
</div>
<div class="sidebar-footer">
<StyledMenu
location="top start"
offset="10"
:close-on-content-click="false"
<v-btn
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
:aria-label="t('core.common.settings')"
@click="settingsOpen = true"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
>
<Settings
:size="20"
:class="['sidebar-action-icon', { 'mr-2': !isSidebarCollapsed }]"
/>
<span v-if="!isSidebarCollapsed">{{
t("core.common.settings")
}}</span>
</v-btn>
</template>
<div class="settings-menu-content">
<v-menu
location="end"
offset="8"
:open-on-hover="!isTouchDevice"
:open-on-click="isTouchDevice"
:close-on-content-click="true"
>
<template #activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Cable :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
tm("transport.title")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentTransportLabel
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="item in transportOptions"
:key="item.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': transportMode === item.value,
}"
rounded="md"
@click="transportMode = item.value"
>
<v-list-item-title>{{
tm(item.labelKey)
}}</v-list-item-title>
<template #append>
<Check
v-if="transportMode === item.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-menu
location="end"
offset="8"
:open-on-hover="!isTouchDevice"
:open-on-click="isTouchDevice"
:close-on-content-click="true"
>
<template #activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Languages :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
t("core.common.language")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentLanguage?.label || locale
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languageOptions"
:key="lang.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': locale === lang.value,
}"
rounded="md"
@click="switchLanguage(lang.value as Locale)"
>
<template #prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.label }}</v-list-item-title>
<template #append>
<Check
v-if="locale === lang.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item
class="styled-menu-item settings-menu-item"
rounded="md"
@click="toggleTheme"
>
<template #prepend>
<Sun
v-if="isDark"
:size="18"
class="styled-menu-lucide-icon"
/>
<Moon v-else :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
isDark ? tm("modes.lightMode") : tm("modes.darkMode")
}}</v-list-item-title>
</v-list-item>
</div>
</StyledMenu>
<Settings
:size="20"
:class="['sidebar-action-icon', { 'mr-2': !isSidebarCollapsed }]"
/>
<span v-if="!isSidebarCollapsed">{{
t("core.common.settings")
}}</span>
</v-btn>
</div>
</v-navigation-drawer>
<ChatSettingsDialog
v-model="settingsOpen"
v-model:enable-streaming="enableStreaming"
v-model:enable-reasoning="enableReasoning"
v-model:send-shortcut="sendShortcut"
v-model:transport-mode="transportMode"
/>
<main
class="chat-main"
:class="{ 'empty-chat': isEmptyChat }"
:class="{
'empty-chat': isEmptyChat,
'has-side-panel':
threadPanelOpen ||
reasoningPanelOpen ||
refsSidebarOpen ||
chatHeader.workspaceFilesOpen,
}"
v-on="dragEvents"
>
<transition name="drop-fade">
<div v-if="isDragging && !isProviderWorkspace" class="chat-drop-overlay">
<div
v-if="isDragging && !isProviderWorkspace"
class="chat-drop-overlay"
>
<div class="chat-drop-overlay-content">
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="chat-drop-text">{{ tm("input.dropToUpload") }}</span>
@@ -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 @@
<section
ref="messagesContainer"
class="messages-panel"
tabindex="0"
@scroll="handleMessagesScroll"
@wheel.passive="handleMessagesInteraction"
@touchstart.passive="handleMessagesInteraction"
@touchmove.passive="handleMessagesInteraction"
@pointerdown="handleMessagesInteraction"
@keydown="handleMessagesInteraction"
>
<div v-if="loadingMessages" class="center-state">
<v-progress-circular indeterminate size="32" width="3" />
@@ -412,6 +289,7 @@
<div
v-if="!loadingMessages && activeMessages.length"
ref="messagesContent"
class="messages-list-shell"
>
<ChatMessageList
@@ -443,6 +321,28 @@
</section>
<section ref="composerShell" class="composer-shell">
<button
v-if="isAwayFromBottom && !shouldStickToBottom"
class="scroll-to-bottom"
type="button"
:aria-label="tm('actions.scrollToBottom')"
:title="tm('actions.scrollToBottom')"
@click="scrollToBottom(true)"
>
<span
v-if="currSessionId && isSessionRunning(currSessionId)"
class="scroll-running-dots"
aria-hidden="true"
>
<span v-for="dot in 3" :key="dot" />
</span>
<ArrowDown
v-else
:size="20"
:stroke-width="1.75"
aria-hidden="true"
/>
</button>
<ChatInput
ref="inputRef"
v-model:prompt="draft"
@@ -450,8 +350,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))
@@ -467,8 +365,6 @@
"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="toggleStreaming"
@toggle-reasoning="enableReasoning = !enableReasoning"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@@ -575,21 +471,16 @@ import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import { isAxiosError } from "axios";
import {
ArrowDown,
Box,
Cable,
Check,
ChevronRight,
Languages,
Moon,
PanelLeft,
Pencil,
Settings,
SquarePen,
Sun,
Trash2,
} from "@lucide/vue";
import { chatApi, providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
@@ -619,12 +510,7 @@ import { useDragUpload } from "@/composables/useDragUpload";
import { useChatHeaderStore } from "@/stores/chatHeader";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
@@ -634,10 +520,13 @@ import {
} from "@/utils/providerMetadata";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
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<TokenProviderConfig[]>([]);
const tokenModelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const selectedTokenProviderId = ref("");
const messagesContainer = ref<HTMLElement | null>(null);
const messagesContent = ref<HTMLElement | null>(null);
const composerShell = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const shouldStickToBottom = ref(true);
const isAwayFromBottom = ref(false);
let lastMessagesScrollTop = 0;
let touchScrollY = 0;
let scrollIntent = 0;
const replyTarget = ref<ChatRecord | null>(null);
const threadPanelOpen = ref(false);
const activeThread = ref<ChatThread | null>(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<TransportMode>(
: "sse",
);
const pointerMediaQuery = window.matchMedia('(pointer: coarse)');
const isTouchDevice = ref<boolean>(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<string, ProviderModelMetadata>;
tokenModelMetadata.value = ((response.data as any).model_metadata ||
{}) as Record<string, ProviderModelMetadata>;
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");
}
</script>
<style scoped>
@@ -1802,13 +1754,17 @@ function toggleTheme() {
}
.chat-ui.is-dark {
--chat-sidebar-bg: #2d2d2d;
--chat-sidebar-bg: #242424;
--chat-session-active-bg: rgba(255, 255, 255, 0.08);
--chat-page-bg: rgb(var(--v-theme-background));
--chat-border: rgba(255, 255, 255, 0.1);
--chat-section-label: rgba(255, 255, 255, 0.5);
}
.chat-ui.is-dark .chat-sidebar {
border-right-color: rgba(255, 255, 255, 0.06);
}
.chat-sidebar {
top: 0 !important;
height: 100vh !important;
@@ -2078,7 +2034,7 @@ function toggleTheme() {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 56px 4px 10px;
padding: 4px 10px;
position: relative;
box-sizing: border-box;
cursor: pointer;
@@ -2090,6 +2046,15 @@ function toggleTheme() {
background: var(--chat-session-active-bg);
}
.session-item.running {
padding-right: 27px;
}
.session-item:hover,
.session-item:focus-within {
padding-right: 56px;
}
.session-title {
min-width: 0;
flex: 1;
@@ -2157,61 +2122,6 @@ function toggleTheme() {
padding-inline: 10px;
}
.settings-menu-content {
min-width: 270px;
padding: 6px;
}
.settings-menu-item {
min-height: 42px;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__prepend) {
width: 28px;
margin-inline-end: 12px;
align-self: center;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__content) {
min-width: 0;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__append) {
margin-inline-start: auto;
padding-inline-start: 18px;
gap: 8px;
align-self: center;
}
.styled-menu-lucide-icon {
flex: 0 0 auto;
color: currentcolor;
stroke-width: 2;
}
.settings-menu-value {
color: var(--chat-muted);
font-size: 12px;
margin-right: 4px;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-flag {
display: inline-block;
width: 20px;
margin-right: 8px;
}
.chat-main {
flex: 1;
min-width: 0;
@@ -2293,6 +2203,7 @@ function toggleTheme() {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
padding: 24px 0 calc(var(--chat-composer-height, 82px) + 34px);
scroll-padding-bottom: calc(var(--chat-composer-height, 82px) + 34px);
}
@@ -2364,6 +2275,67 @@ function toggleTheme() {
padding: 0 0 18px;
}
.scroll-to-bottom {
position: absolute;
top: -48px;
left: 50%;
transform: translateX(-50%);
z-index: 2;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 50%;
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
pointer-events: auto;
}
.scroll-to-bottom:hover {
background: rgb(var(--v-theme-surface-variant));
}
.scroll-running-dots {
display: flex;
gap: 3px;
}
.scroll-running-dots span {
width: 3px;
height: 3px;
border-radius: 50%;
background: currentColor;
animation: scroll-dot-pulse 1.2s ease-in-out infinite;
}
.scroll-running-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.scroll-running-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes scroll-dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.scroll-running-dots span {
animation: none;
}
}
.conversation-stack:not(.is-empty) .composer-shell {
position: absolute;
right: 0;
@@ -2387,6 +2359,7 @@ function toggleTheme() {
.composer-shell :deep(.input-area),
.project-composer-shell :deep(.input-area) {
padding-top: 0;
padding-inline: 0;
border-top: 0;
}
@@ -2412,21 +2385,33 @@ kbd {
font: inherit;
}
:deep(.hr-node) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
opacity: 0.5;
border-top-width: .3px;
}
:deep(.paragraph-node) {
margin: .5rem 0;
line-height: 1.7;
margin: 0.5rem 0;
}
:deep(.list-node) {
margin-top: .5rem;
margin-bottom: .5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
@media (min-width: 761px) {
.chat-main.has-side-panel {
--chat-content-width: calc(100% - 40px);
}
.messages-list-shell,
.composer-shell :deep(.input-container) {
transition:
width 320ms cubic-bezier(0.22, 1, 0.36, 1),
max-width 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.messages-list-shell,
.composer-shell :deep(.input-container) {
transition: none;
}
}
@media (max-width: 760px) {
+120 -464
View File
@@ -1,25 +1,10 @@
<template>
<div
class="input-area fade-in"
:class="{ 'is-dark': isDark }"
>
<div class="input-area fade-in" :class="{ 'is-dark': isDark }">
<div
class="input-container"
:class="{
'is-multiline': inputIsMultiline,
'has-attachments': hasStagedAttachments,
}"
:style="{
width: 'var(--chat-content-width, 76%)',
maxWidth: 'var(--chat-content-max-width, 760px)',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
backgroundColor: isDark ? '#2d2d2d' : '#fff',
position: 'relative',
transition: 'min-height 0.2s ease, padding 0.2s ease',
}"
>
<!-- 引用预览区 -->
<transition name="slideReply" @after-leave="handleReplyAfterLeave">
@@ -110,6 +95,27 @@
/>
<div class="composer-row">
<div class="input-field-shell">
<textarea
rows="1"
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="handleBlur"
@paste="handlePaste"
:disabled="disabled"
:placeholder="props.placeholder || tm('input.placeholder')"
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
></textarea>
</div>
<div class="input-left-actions">
<!-- Settings Menu -->
<StyledMenu
@@ -120,10 +126,13 @@
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
icon="mdi-plus"
variant="outlined"
icon
variant="text"
class="input-neutral-btn input-outline-control"
/>
:aria-label="tm('input.upload')"
>
<Plus :size="18" :stroke-width="1.75" />
</v-btn>
</template>
<!-- Upload Files -->
@@ -149,82 +158,20 @@
@config-changed="handleConfigChange"
/>
<!-- Streaming Toggle in Menu -->
<v-list-item
v-if="showSettings"
class="styled-menu-item"
rounded="md"
@click="$emit('toggleStreaming')"
@click="$emit('openSettings')"
>
<template v-slot:prepend>
<v-icon icon="mdi-lightning-bolt" size="small"></v-icon>
<template #prepend>
<v-icon icon="mdi-cog-outline" size="small" />
</template>
<v-list-item-title>
{{
enableStreaming
? tm("streaming.enabled")
: tm("streaming.disabled")
}}
</v-list-item-title>
</v-list-item>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="$emit('toggleReasoning')"
>
<template v-slot:prepend>
<v-icon icon="mdi-brain" size="small"></v-icon>
</template>
<v-list-item-title>
{{
enableReasoning
? tm("reasoning.enabled")
: tm("reasoning.disabled")
}}
</v-list-item-title>
<v-list-item-title>{{
t("core.common.settings")
}}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div class="input-field-shell">
<input
v-if="!inputIsMultiline"
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="handleBlur"
@paste="handlePaste"
:disabled="disabled"
:placeholder="props.placeholder || tm('input.placeholder')"
class="chat-text-input"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
type="text"
/>
<textarea
v-else
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
@input="handleInput"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@compositioncancel="handleCompositionEnd"
@blur="handleBlur"
@paste="handlePaste"
:disabled="disabled"
:placeholder="props.placeholder || tm('input.placeholder')"
class="chat-textarea"
autocomplete="off"
autocorrect="off"
autocapitalize="sentences"
spellcheck="false"
></textarea>
</div>
<div class="input-right-actions">
<input
@@ -246,11 +193,7 @@
class="mr-1"
width="1.5"
/>
<v-tooltip
v-if="tokenUsageVisible"
location="top"
max-width="320"
>
<v-tooltip v-if="tokenUsageVisible" location="top" max-width="320">
<template #activator="{ props: tokenTooltipProps }">
<span
v-bind="tokenTooltipProps"
@@ -259,36 +202,22 @@
>
<v-progress-circular
:model-value="tokenUsagePercent"
size="24"
width="2.5"
size="20"
width="2"
class="token-usage-progress"
/>
</span>
</template>
<span>{{ props.tokenUsage?.tooltip }}</span>
</v-tooltip>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
color="purple"
size="small"
>
<v-icon icon="mdi-phone-in-talk" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn
@click="handleRecordClick"
icon
variant="text"
class="record-btn input-icon-btn"
>
<v-icon
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'"
variant="text"
plain
></v-icon>
<CircleStop v-if="isRecording" :size="18" :stroke-width="1.75" />
<Mic v-else :size="18" :stroke-width="1.75" />
<v-tooltip activator="parent" location="top">
{{
isRecording ? tm("voice.speaking") : tm("voice.startRecording")
@@ -299,10 +228,11 @@
icon
v-if="isRunning && !canSend"
@click="$emit('stop')"
variant="tonal"
variant="flat"
color="primary"
class="send-btn input-action-btn"
>
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<Square :size="14" :stroke-width="1.75" />
<v-tooltip activator="parent" location="top">
{{ tm("input.stopGenerating") }}
</v-tooltip>
@@ -310,11 +240,15 @@
<v-btn
v-else
@click="$emit('send')"
icon="mdi-arrow-up"
variant="tonal"
icon
variant="flat"
color="primary"
:disabled="!canSend"
:aria-label="tm('input.send')"
class="send-btn input-action-btn"
/>
>
<ArrowUp :size="18" :stroke-width="1.75" />
</v-btn>
</div>
</div>
</div>
@@ -331,7 +265,8 @@ import {
onBeforeUnmount,
} from "vue";
import { useDisplay } from "vuetify";
import { useModuleI18n } from "@/i18n/composables";
import { ArrowUp, CircleStop, Mic, Plus, Square } from "@lucide/vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { isComposingEnter } from "@/utils/imeInput.mjs";
import { commandApi } from "@/api/v1";
@@ -370,8 +305,7 @@ interface Props {
stagedAudioUrl: string;
stagedFiles?: StagedFileInfo[];
disabled: boolean;
enableStreaming: boolean;
enableReasoning: boolean;
showSettings?: boolean;
isRecording: boolean;
isRunning: boolean;
sessionId?: string | null;
@@ -392,6 +326,7 @@ const props = withDefaults(defineProps<Props>(), {
replyTo: null,
sendShortcut: "shift_enter",
showProviderSelector: true,
showSettings: false,
tokenUsage: null,
});
@@ -399,8 +334,7 @@ const emit = defineEmits<{
"update:prompt": [value: string];
send: [];
stop: [];
toggleStreaming: [];
toggleReasoning: [];
openSettings: [];
removeImage: [index: number];
removeAudio: [];
removeFile: [index: number];
@@ -413,11 +347,12 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n("features/chat");
const { t } = useI18n();
const isDark = computed(
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
);
const inputField = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
null,
@@ -425,7 +360,6 @@ const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(
const providerSelectorAvailable = ref(true);
const isReplyClosing = ref(false);
const isComposing = ref(false);
const inputIsMultiline = ref(false);
const lastCompositionEndAt = ref<number | null>(null);
const longPasteThreshold = 10_000;
@@ -530,7 +464,9 @@ const filteredCommands = computed(() => {
for (const cmd of enabledCommands.value) {
const commandText = normalizeCommandSearchText(cmd.effective_command);
const pluginText = normalizeCommandSearchText(cmd.plugin_display_name || "");
const pluginText = normalizeCommandSearchText(
cmd.plugin_display_name || "",
);
const descriptionText = normalizeCommandSearchText(cmd.description || "");
const matchesCommand = commandText.includes(query);
const matchesMetadata =
@@ -632,78 +568,25 @@ const tokenUsageColor = computed(() =>
function autoResize() {
const el = inputField.value;
if (!el) return;
if (!(el instanceof HTMLTextAreaElement)) {
const shouldExpand =
localPrompt.value.includes("\n") ||
(el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 4);
if (shouldExpand) {
const cursor = el.selectionStart ?? localPrompt.value.length;
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
}
return;
}
const isMobileViewport =
typeof window !== "undefined" &&
window.matchMedia("(max-width: 768px)").matches;
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 900;
const minHeight = isMobileViewport ? 56 : 52;
const minHeight = 48;
const maxHeight = isMobileViewport
? Math.min(220, Math.round(viewportHeight * 0.42))
: Math.min(420, Math.round(viewportHeight * 0.48));
if (!localPrompt.value) {
inputIsMultiline.value = false;
el.style.height = minHeight + "px";
return;
}
el.style.height = "auto";
el.style.setProperty("min-height", "0", "important");
const measuredHeight = el.scrollHeight;
el.style.removeProperty("min-height");
const computed = getComputedStyle(el);
let lineHeight = parseFloat(computed.lineHeight);
if (!Number.isFinite(lineHeight)) {
lineHeight = parseFloat(computed.fontSize) * 1.2;
}
const paddingVertical =
parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom);
const shouldUseMultiline =
localPrompt.value.includes("\n") ||
measuredHeight > lineHeight + paddingVertical + 0.5;
if (inputIsMultiline.value !== shouldUseMultiline) {
const cursor = el.selectionStart ?? localPrompt.value.length;
inputIsMultiline.value = shouldUseMultiline;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
return;
}
el.style.height = shouldUseMultiline
? Math.min(Math.max(measuredHeight, minHeight), maxHeight) + "px"
: minHeight + "px";
el.style.height =
Math.min(Math.max(el.scrollHeight, minHeight), maxHeight) + "px";
}
watch(
() => props.prompt,
(value) => {
if (!value) {
inputIsMultiline.value = false;
}
nextTick(autoResize);
},
() => nextTick(autoResize),
);
watch(inputIsMultiline, () => {
nextTick(autoResize);
});
function handleKeyDown(e: KeyboardEvent) {
// 命令提示激活时,拦截方向键和 Enter/Esc
if (showCommandSuggestion.value && filteredCommands.value.length > 0) {
@@ -771,22 +654,6 @@ function handleKeyDown(e: KeyboardEvent) {
if (canSend.value) {
emit("send");
}
return;
}
if (!inputIsMultiline.value) {
e.preventDefault();
const target = e.target as HTMLInputElement;
const start = target.selectionStart ?? localPrompt.value.length;
const end = target.selectionEnd ?? start;
localPrompt.value =
localPrompt.value.slice(0, start) + "\n" + localPrompt.value.slice(end);
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
inputField.value?.setSelectionRange(start + 1, start + 1);
autoResize();
});
}
}
@@ -826,7 +693,9 @@ async function fetchCommands() {
commandSuggestionLoading.value = true;
try {
const cid = currentConfigId.value;
const res = await commandApi.list(cid && cid !== "default" ? cid : undefined);
const res = await commandApi.list(
cid && cid !== "default" ? cid : undefined,
);
if (res.data.status === "ok") {
allCommands.value = res.data.data.items || [];
// 读取当前配置的唤醒词列表,用于指令候选的触发前缀
@@ -911,23 +780,6 @@ function handlePaste(e: ClipboardEvent) {
return;
}
if (!inputIsMultiline.value && pastedText.includes("\n")) {
e.preventDefault();
const target = e.target as HTMLInputElement;
const start = target.selectionStart ?? localPrompt.value.length;
const end = target.selectionEnd ?? start;
localPrompt.value =
localPrompt.value.slice(0, start) +
pastedText +
localPrompt.value.slice(end);
inputIsMultiline.value = true;
nextTick(() => {
inputField.value?.focus();
const cursor = start + pastedText.length;
inputField.value?.setSelectionRange(cursor, cursor);
autoResize();
});
}
emit("pasteImage", e);
}
@@ -1006,7 +858,7 @@ defineExpose({
}
.input-neutral-btn {
color: #6f6f6f !important;
color: #000 !important;
}
.input-neutral-btn:hover {
@@ -1022,24 +874,26 @@ defineExpose({
background: #e7e7e7;
}
.input-action-btn {
background: #5594c6 !important;
color: #fff !important;
.input-action-btn,
.input-outline-control,
.input-icon-btn {
width: 28px !important;
height: 28px !important;
min-width: 28px !important;
}
.input-action-btn:hover {
background: #4c86b3 !important;
}
.input-action-btn:disabled {
background: rgba(85, 148, 198, 0.24) !important;
color: rgba(255, 255, 255, 0.72) !important;
.input-area.is-dark .input-action-btn:not(.v-btn--disabled) {
background-color: color-mix(
in srgb,
rgb(var(--v-theme-primary)) 85%,
#000
) !important;
}
.input-icon-btn {
background: transparent !important;
color: rgb(var(--v-theme-on-surface)) !important;
margin-right: 8px;
margin-right: 0;
}
.input-icon-btn:hover {
@@ -1047,12 +901,12 @@ defineExpose({
}
.token-usage-indicator {
width: 24px;
height: 24px;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
flex: 0 0 20px;
border-radius: 50%;
color: var(--token-usage-color);
}
@@ -1072,9 +926,7 @@ defineExpose({
}
.input-outline-control {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
border-radius: 50% !important;
border: 0 !important;
border-color: transparent !important;
background: transparent !important;
@@ -1107,48 +959,23 @@ defineExpose({
background: rgba(255, 255, 255, 0.06) !important;
}
.input-area.is-dark .input-action-btn {
background: rgb(var(--v-theme-on-surface)) !important;
color: rgb(var(--v-theme-surface)) !important;
}
.input-area.is-dark .input-action-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.86) !important;
}
.input-area.is-dark .input-action-btn:disabled {
background: rgba(var(--v-theme-on-surface), 0.14) !important;
color: rgba(var(--v-theme-on-surface), 0.4) !important;
}
.input-container {
position: relative;
width: var(--chat-content-width, 76%);
max-width: var(--chat-content-max-width, 760px);
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 64px;
padding: 6px 12px 6px 14px !important;
border-color: #f0f0f0 !important;
border-radius: 999px !important;
padding: 8px 14px !important;
border: 1px solid #d9d9d9;
border-radius: 20px !important;
background: #fff !important;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06) !important;
}
.input-container.is-multiline {
justify-content: flex-start;
padding: 16px 20px 14px !important;
border-radius: 34px !important;
}
.input-container.has-attachments {
justify-content: flex-start;
min-height: 130px;
padding: 14px 18px 10px !important;
border-radius: 30px !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
}
.input-area.is-dark .input-container {
border: 1px solid rgba(255, 255, 255, 0.12) !important;
background: #2d2d2d !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
background: #242424 !important;
box-shadow: none !important;
}
@@ -1160,79 +987,54 @@ defineExpose({
.composer-row {
width: 100%;
min-height: 52px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "left field right";
align-items: center;
column-gap: 10px;
}
.input-container.is-multiline .composer-row {
grid-template-areas:
"field field field"
"left . right";
row-gap: 10px;
align-items: end;
align-items: center;
gap: 4px 8px;
}
.input-field-shell {
grid-area: field;
min-width: 0;
min-height: 52px;
display: flex;
align-items: center;
}
.input-container.is-multiline .input-field-shell {
min-height: auto;
align-items: flex-start;
}
.chat-text-input,
.chat-textarea {
display: block;
width: 100%;
box-sizing: border-box;
min-width: 0;
min-height: 52px !important;
max-height: 72px !important;
min-height: 48px;
max-height: min(48vh, 420px);
margin: 0;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
padding: 4px 2px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
resize: none;
outline: none;
font-family: inherit;
font-size: 18px !important;
}
.chat-text-input {
height: 52px !important;
padding: 0 !important;
line-height: normal !important;
overflow: hidden;
}
.chat-textarea {
max-height: min(48vh, 420px) !important;
padding: 12px 0 !important;
font-size: 14px;
line-height: 22px;
overflow-y: auto;
overflow-wrap: break-word;
line-height: 28px !important;
transition: height 0.16s ease;
}
.chat-text-input::placeholder,
.chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.56);
opacity: 1;
}
.input-area.is-dark .chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.4);
}
.input-left-actions {
grid-area: left;
margin-inline-start: -8px;
display: flex;
align-items: center;
flex: 0 0 auto !important;
@@ -1249,50 +1051,16 @@ defineExpose({
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
gap: 10px;
gap: 8px;
margin-top: 0 !important;
}
.input-outline-control {
width: 34px !important;
height: 34px !important;
min-width: 34px !important;
border: 0 !important;
border-color: transparent !important;
border-radius: 50% !important;
box-shadow: none !important;
}
.input-icon-btn {
width: 42px !important;
height: 42px !important;
min-width: 42px !important;
margin-right: 0;
}
.input-right-actions :deep(.provider-chip) {
height: 40px !important;
min-height: 40px !important;
height: 28px !important;
min-height: 28px !important;
border-radius: 999px !important;
}
.input-area:not(.is-dark) .input-action-btn {
width: 46px !important;
height: 46px !important;
min-width: 46px !important;
background: #8fcfb4 !important;
color: #fff !important;
}
.input-area:not(.is-dark) .input-action-btn:hover {
background: #7fc4a8 !important;
}
.input-area:not(.is-dark) .input-action-btn:disabled {
background: #f2f5f3 !important;
color: rgba(0, 0, 0, 0.18) !important;
}
.reply-preview {
display: flex;
align-items: center;
@@ -1534,68 +1302,15 @@ defineExpose({
}
.input-container {
display: flex !important;
flex-direction: column;
justify-content: center;
width: calc(100% - 20px) !important;
max-width: 100% !important;
min-height: 64px;
margin: 0 10px calc(8px + env(safe-area-inset-bottom)) !important;
padding: 6px 8px 6px 10px !important;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14) !important;
border-radius: 999px !important;
background: #fff !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important;
}
.input-container.is-multiline {
justify-content: flex-start;
min-height: 128px;
padding: 10px !important;
border-radius: 26px !important;
}
.input-container.has-attachments {
justify-content: flex-start;
min-height: 124px;
padding: 10px !important;
border-radius: 26px !important;
}
.input-area.is-dark .input-container {
border-color: rgba(255, 255, 255, 0.16) !important;
background: #2d2d2d !important;
box-shadow: none !important;
padding: 8px 10px !important;
}
.composer-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "left field right";
min-height: 52px;
row-gap: 0;
column-gap: 8px;
align-items: center;
}
.input-container.is-multiline .composer-row {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"field field field"
"left . right";
min-height: auto;
row-gap: 4px;
}
.input-field-shell {
min-height: 52px;
align-items: center;
}
.input-container.is-multiline .input-field-shell {
min-height: 56px;
align-items: flex-start;
column-gap: 8px;
}
.input-left-actions,
@@ -1608,73 +1323,14 @@ defineExpose({
gap: 6px;
}
.input-outline-control {
width: 38px !important;
height: 38px !important;
min-width: 38px !important;
border: 0 !important;
border-color: transparent !important;
border-radius: 50% !important;
}
.chat-text-input,
.chat-textarea {
min-height: 52px !important;
max-height: 132px !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 18px !important;
}
.chat-text-input {
height: 52px !important;
padding: 0 2px !important;
line-height: normal !important;
overflow: hidden;
}
.chat-textarea {
max-height: min(42vh, 220px) !important;
padding: 4px 10px 2px !important;
line-height: 24px !important;
overflow-y: auto;
}
.chat-text-input::placeholder,
.chat-textarea::placeholder {
color: rgba(var(--v-theme-on-surface), 0.56);
opacity: 1;
}
.input-icon-btn {
width: 38px !important;
height: 38px !important;
min-width: 38px !important;
margin-right: 0;
}
.input-action-btn {
width: 42px !important;
height: 42px !important;
min-width: 42px !important;
border-radius: 50% !important;
}
.input-action-btn:not(:disabled) {
background: rgb(var(--v-theme-on-surface)) !important;
color: rgb(var(--v-theme-surface)) !important;
}
.input-action-btn:disabled {
background: rgba(var(--v-theme-on-surface), 0.04) !important;
color: rgba(var(--v-theme-on-surface), 0.18) !important;
font-size: 16px;
max-height: min(42vh, 220px);
}
:deep(.provider-chip) {
height: 38px !important;
min-height: 38px !important;
height: 28px !important;
min-height: 28px !important;
border-radius: 999px !important;
padding: 0 12px !important;
font-size: 14px !important;
+282 -230
View File
@@ -11,15 +11,12 @@
class="message-row"
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
>
<v-avatar v-if="!isUserMessage(msg)" class="bot-avatar" :size="avatarSize">
<v-progress-circular
v-if="isMessageStreaming(msg, msgIndex)"
class="bot-streaming-spinner"
indeterminate
size="22"
width="2"
/>
<span v-else class="bot-avatar-symbol" aria-hidden="true">✦</span>
<v-avatar
v-if="!isUserMessage(msg)"
class="bot-avatar"
:size="avatarSize"
>
<span class="bot-avatar-symbol" aria-hidden="true">✦</span>
</v-avatar>
<div class="message-stack">
@@ -85,220 +82,236 @@
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
@mouseup="handleMouseUp($event, msg)"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
<span>{{ tm("message.loading") }}</span>
</div>
<template v-else-if="isEditingMessage(msg)">
<div class="inline-message-editor">
<textarea
:value="editDraft"
class="inline-message-editor-input"
rows="2"
autofocus
@input="
emit(
'update:editDraft',
($event.target as HTMLTextAreaElement).value,
)
"
@keydown.esc="emit('cancelEdit')"
></textarea>
<div class="inline-message-editor-actions">
<v-btn
class="inline-message-editor-action"
size="small"
variant="text"
@click="emit('cancelEdit')"
>
{{ t("core.common.cancel") }}
</v-btn>
<v-btn
class="inline-message-editor-action"
size="small"
color="primary"
variant="tonal"
:loading="savingEdit"
@click="emit('saveEdit')"
>
{{ t("core.common.save") }}
</v-btn>
<MessageContentTransition :loading="messageContent(msg).isLoading">
<template v-if="isEditingMessage(msg)">
<div class="inline-message-editor">
<textarea
:value="editDraft"
class="inline-message-editor-input"
rows="2"
autofocus
@input="
emit(
'update:editDraft',
($event.target as HTMLTextAreaElement).value,
)
"
@keydown.esc="emit('cancelEdit')"
></textarea>
<div class="inline-message-editor-actions">
<v-btn
class="inline-message-editor-action"
size="small"
variant="text"
@click="emit('cancelEdit')"
>
{{ t("core.common.cancel") }}
</v-btn>
<v-btn
class="inline-message-editor-action"
size="small"
color="primary"
variant="tonal"
:loading="savingEdit"
@click="emit('saveEdit')"
>
{{ t("core.common.save") }}
</v-btn>
</div>
</div>
</div>
</template>
</template>
<template v-else>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:open-in-sidebar="variant === 'main'"
@open="emit('openReasoning', { message: msg, blockIndex })"
/>
<template v-else>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:open-in-sidebar="variant === 'main'"
@open="emit('openReasoning', { message: msg, blockIndex })"
/>
<template v-else>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
<template v-else>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{
replyPreview(part.message_id, part.selected_text)
}}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
<div
v-else-if="
part.type === 'plain' && messageThreads(msg).length
"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:is-streaming="isMessageStreaming(msg, msgIndex)"
@open-thread="emit('openThread', $event)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:is-streaming="isMessageStreaming(msg, msgIndex)"
@open-thread="emit('openThread', $event)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:is-streaming="isMessageStreaming(msg, msgIndex)"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">
{{ attachmentName(part) }}
</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
<v-btn
class="file-part-action"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
/>
</div>
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
<img
:src="partUrl(part)"
:alt="part.filename || 'image'"
/>
</template>
</div>
</button>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color':
attachmentPresentation(part).color,
}"
>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">
{{ attachmentName(part) }}
</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
<v-btn
class="file-part-action"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
/>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</template>
</template>
</template>
</MessageContentTransition>
</div>
<div v-if="showMessageMeta(msg, msgIndex)" class="message-meta">
<span v-if="msg.created_at">{{ formatTime(msg.created_at) }}</span>
<span v-if="isUserMessage(msg) && msg.created_at">{{
formatTime(msg.created_at)
}}</span>
<v-btn
v-if="canEditMessage(msg, msgIndex)"
icon="mdi-pencil-outline"
icon
size="x-small"
variant="text"
@click="emit('openEdit', msg)"
/>
>
<SquarePen :size="14" :stroke-width="2" />
</v-btn>
<RegenerateMenu
v-if="canRegenerateMessage(msg, msgIndex)"
@retry="emit('regenerate', msg)"
@@ -306,23 +319,22 @@
/>
<v-btn
v-if="enableCopy && !isUserMessage(msg)"
icon="mdi-content-copy"
icon
size="x-small"
variant="text"
@click="copyMessage(msg)"
/>
>
<Copy :size="14" :stroke-width="2" />
</v-btn>
<v-menu
v-if="messageContent(msg).agentStats"
location="bottom"
transition="none"
>
<template #activator="{ props: statsProps }">
<v-btn
v-bind="statsProps"
icon="mdi-information-outline"
size="x-small"
variant="text"
/>
<v-btn v-bind="statsProps" icon size="x-small" variant="text">
<Info :size="14" :stroke-width="2" />
</v-btn>
</template>
<v-card class="stats-card" elevation="4">
<div
@@ -336,19 +348,30 @@
</div>
<div class="stats-row">
<span>{{ tm("stats.inputTokens") }}</span>
<strong>{{ inputTokens(messageContent(msg).agentStats) }}</strong>
<strong>{{
inputTokens(messageContent(msg).agentStats)
}}</strong>
</div>
<div class="stats-row">
<span>{{ tm("stats.outputTokens") }}</span>
<strong>{{ outputTokens(messageContent(msg).agentStats) }}</strong>
<strong>{{
outputTokens(messageContent(msg).agentStats)
}}</strong>
</div>
<div v-if="agentTtft(messageContent(msg).agentStats)" class="stats-row">
<div
v-if="agentTtft(messageContent(msg).agentStats)"
class="stats-row"
>
<span>{{ tm("stats.ttft") }}</span>
<strong>{{ agentTtft(messageContent(msg).agentStats) }}</strong>
<strong>{{
agentTtft(messageContent(msg).agentStats)
}}</strong>
</div>
<div class="stats-row">
<span>{{ tm("stats.duration") }}</span>
<strong>{{ agentDuration(messageContent(msg).agentStats) }}</strong>
<strong>{{
agentDuration(messageContent(msg).agentStats)
}}</strong>
</div>
</v-card>
</v-menu>
@@ -365,7 +388,9 @@
type="button"
>
<v-icon size="14">mdi-source-branch</v-icon>
<span>{{ threadCountLabel(messageThreads(msg).length) }}</span>
<span>{{
threadCountLabel(messageThreads(msg).length)
}}</span>
</button>
</template>
<v-list-item
@@ -389,6 +414,11 @@
@open-refs="handleOpenRefs"
/>
</div>
<span
v-if="!isUserMessage(msg) && msg.created_at"
class="message-time"
>{{ formatTime(msg.created_at) }}</span
>
</div>
</div>
</div>
@@ -417,7 +447,9 @@
</template>
<script setup lang="ts">
import MessageContentTransition from "@/components/chat/MessageContentTransition.vue";
import { computed, nextTick, reactive, ref } from "vue";
import { Copy, Info, SquarePen } from "@lucide/vue";
import axios from "axios";
import { fileApi } from "@/api/v1";
import RegenerateMenu, {
@@ -539,13 +571,13 @@ function userAttachmentParts(message: ChatRecord) {
function hasImageOnlyAttachments(message: ChatRecord) {
const attachments = userAttachmentParts(message);
return (
attachments.length > 0 &&
attachments.every((part) => part.type === "image")
attachments.length > 0 && attachments.every((part) => part.type === "image")
);
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return displayMessageParts(messageContent(message));
if (!isUserMessage(message))
return displayMessageParts(messageContent(message));
return messageParts(message).filter((part) => !isAttachmentPart(part));
}
@@ -732,8 +764,8 @@ function normalizeRefs(refs: unknown) {
const used = Array.isArray((refs as any)?.used)
? (refs as any).used
: Array.isArray(refs)
? refs
: [];
? refs
: [];
return { used: normalizeRefItems(used) };
}
@@ -875,6 +907,7 @@ function formatDuration(seconds: number) {
<style scoped>
.chat-message-list {
container: chat-messages / inline-size;
--chat-border: rgba(var(--v-border-color), 0.16);
--chat-muted: rgba(var(--v-theme-on-surface), 0.62);
width: 100%;
@@ -1026,10 +1059,6 @@ function formatDuration(seconds: number) {
user-select: none;
}
.bot-streaming-spinner {
margin-top: -4px;
}
.bot-avatar-symbol {
display: inline-flex;
align-items: center;
@@ -1053,8 +1082,8 @@ function formatDuration(seconds: number) {
padding: 12px 18px;
font-size: 15px;
max-width: 100%;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
border-radius: 16px;
background: rgba(var(--v-theme-primary), 0.16);
}
.message-bubble.bot {
@@ -1101,14 +1130,6 @@ function formatDuration(seconds: number) {
border-radius: 14px;
}
.loading-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
color: var(--chat-muted);
}
.chat-message-list :deep(.markdown-content p) {
margin: 0.25rem 0;
}
@@ -1260,6 +1281,14 @@ function formatDuration(seconds: number) {
align-items: center;
}
.from-bot .message-meta :deep(> .v-btn:first-child) {
margin-inline-start: -6px;
}
.from-bot .message-time:not(:first-child) {
margin-inline-start: 6px;
}
.message-thread-meta {
min-height: 24px;
display: inline-flex;
@@ -1296,6 +1325,19 @@ function formatDuration(seconds: number) {
justify-content: flex-end;
}
@media (hover: hover) and (pointer: fine) {
.from-user .message-meta {
opacity: 0;
pointer-events: none;
}
.from-user:hover .message-meta,
.from-user:focus-within .message-meta {
opacity: 1;
pointer-events: auto;
}
}
.stats-card {
min-width: 150px;
padding: 8px 10px;
@@ -1378,6 +1420,16 @@ function formatDuration(seconds: number) {
padding-left: 12px;
}
@container chat-messages (max-width: 600px) {
.message-row.from-bot .bot-avatar {
display: none;
}
.message-bubble.bot {
padding-inline: 0;
}
}
@media (max-width: 760px) {
.messages-list {
gap: 18px;
@@ -0,0 +1,358 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="880"
height="580"
max-height="calc(100dvh - 48px)"
:aria-label="t('core.common.settings')"
@update:model-value="emit('update:modelValue', $event)"
>
<v-card class="chat-settings">
<aside class="settings-sidebar">
<div class="settings-sidebar-header">
<v-btn
icon="mdi-close"
variant="text"
size="small"
:aria-label="t('core.common.close')"
@click="emit('update:modelValue', false)"
/>
<span>{{ t("core.common.settings") }}</span>
</div>
<nav class="settings-nav" :aria-label="t('core.common.settings')">
<button
v-for="section in sections"
:key="section.id"
type="button"
class="settings-nav-item"
:class="{ active: activeSection === section.id }"
:aria-pressed="activeSection === section.id"
@click="activeSection = section.id"
>
<v-icon :icon="section.icon" size="18" />
{{ section.title }}
</button>
</nav>
</aside>
<div class="settings-main">
<v-card-title class="text-h3 pa-4 pb-0 pl-6 settings-heading">
{{ tm(`settings.${activeSection}`) }}
</v-card-title>
<v-card-text class="settings-content">
<div v-if="activeSection === 'general'" class="settings-list">
<div class="settings-item">
<div class="settings-item-title">
{{ tm("settings.appearance") }}
</div>
<v-select
:model-value="customizer.themeMode"
:items="themeOptions"
:aria-label="tm('settings.appearance')"
variant="outlined"
density="compact"
hide-details
@update:model-value="customizer.SET_THEME_MODE($event)"
/>
</div>
<div class="settings-item">
<div class="settings-item-title">
{{ t("core.common.language") }}
</div>
<v-select
:model-value="locale"
:items="languageOptions"
item-title="label"
item-value="value"
:aria-label="t('core.common.language')"
variant="outlined"
density="compact"
hide-details
@update:model-value="switchLanguage($event)"
/>
</div>
</div>
<div v-else class="settings-list">
<div class="settings-item settings-item-switch">
<div>
<div class="settings-item-title">
{{ tm("settings.streaming") }}
</div>
<div class="settings-item-subtitle">
{{ tm("settings.streamingHint") }}
</div>
</div>
<v-switch
:model-value="enableStreaming"
:aria-label="tm('settings.streaming')"
color="primary"
inset
hide-details
density="compact"
@update:model-value="emit('update:enableStreaming', !!$event)"
/>
</div>
<div class="settings-item settings-item-switch">
<div>
<div class="settings-item-title">
{{ tm("settings.reasoning") }}
</div>
<div class="settings-item-subtitle">
{{ tm("settings.reasoningHint") }}
</div>
</div>
<v-switch
:model-value="enableReasoning"
:aria-label="tm('settings.reasoning')"
color="primary"
inset
hide-details
density="compact"
@update:model-value="emit('update:enableReasoning', !!$event)"
/>
</div>
<div class="settings-item">
<div class="settings-item-title">
{{ tm("shortcuts.sendKey.title") }}
</div>
<v-select
:model-value="sendShortcut"
:items="shortcutOptions"
:aria-label="tm('shortcuts.sendKey.title')"
variant="outlined"
density="compact"
hide-details
@update:model-value="emit('update:sendShortcut', $event)"
/>
</div>
<div class="settings-item">
<div class="settings-item-title">{{ tm("transport.title") }}</div>
<v-select
:model-value="transportMode"
:items="transportOptions"
:aria-label="tm('transport.title')"
variant="outlined"
density="compact"
hide-details
@update:model-value="emit('update:transportMode', $event)"
/>
</div>
</div>
</v-card-text>
</div>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import type { TransportMode } from "@/composables/useMessages";
defineProps<{
modelValue: boolean;
enableStreaming: boolean;
enableReasoning: boolean;
sendShortcut: "enter" | "shift_enter";
transportMode: TransportMode;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
"update:enableStreaming": [value: boolean];
"update:enableReasoning": [value: boolean];
"update:sendShortcut": [value: "enter" | "shift_enter"];
"update:transportMode": [value: TransportMode];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const { locale, languageOptions, switchLanguage } = useLanguageSwitcher();
const customizer = useCustomizerStore();
const activeSection = ref("general");
const sections = computed(() => [
{ id: "general", title: tm("settings.general"), icon: "mdi-cog-outline" },
{ id: "chat", title: tm("settings.chat"), icon: "mdi-message-outline" },
]);
const themeOptions = computed(() => [
{ title: tm("settings.light"), value: "light" },
{ title: tm("settings.dark"), value: "dark" },
{ title: tm("settings.system"), value: "system" },
]);
const shortcutOptions = computed(() => [
{ title: tm("shortcuts.sendKey.enterToSend"), value: "enter" },
{ title: tm("shortcuts.sendKey.shiftEnterToSend"), value: "shift_enter" },
]);
const transportOptions = computed(() => [
{ title: tm("transport.sse"), value: "sse" },
{ title: tm("transport.websocket"), value: "websocket" },
]);
</script>
<style scoped>
.chat-settings {
--settings-border: rgba(var(--v-theme-on-surface), 0.13);
--settings-divider: rgba(var(--v-theme-on-surface), 0.09);
display: grid !important;
grid-template-columns: 200px minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: hidden !important;
border: 1px solid var(--settings-border);
border-radius: 20px !important;
background: rgb(var(--v-theme-surface));
}
.settings-sidebar {
padding: 16px 12px;
border-right: 1px solid var(--settings-divider);
}
.settings-sidebar-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 0.9rem;
font-weight: 700;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-nav-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 8px 12px;
border-radius: 8px;
color: rgb(var(--v-theme-on-surface));
font: inherit;
font-size: 0.88rem;
font-weight: 680;
text-align: left;
}
.settings-nav-item:hover {
background: rgba(var(--v-theme-on-surface), 0.045);
}
.settings-nav-item.active {
background: rgba(var(--v-theme-on-surface), 0.07);
color: rgb(var(--v-theme-on-surface));
}
.settings-nav-item:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: 2px;
}
.settings-main {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.settings-heading {
flex: 0 0 auto;
padding-top: 28px !important;
font-weight: 780;
}
.settings-content {
min-height: 0;
overflow-y: auto;
padding: 24px !important;
}
.settings-list {
border: 1px solid var(--settings-border);
border-radius: 10px;
overflow: hidden;
}
.settings-item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(160px, 42%);
gap: 18px;
align-items: center;
min-height: 76px;
padding: 16px;
border-bottom: 1px solid var(--settings-divider);
}
.settings-item:last-child {
border-bottom: 0;
}
.settings-item-title {
font-size: 0.88rem;
font-weight: 700;
line-height: 1.4;
}
.settings-item-subtitle {
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.64);
font-size: 0.78rem;
line-height: 1.5;
}
.settings-item-switch {
grid-template-columns: minmax(0, 1fr) auto;
}
.settings-item :deep(.v-field) {
border-radius: 10px;
font-size: 0.86rem;
}
.settings-item :deep(.v-input) {
min-width: 0;
}
@media (max-width: 600px) {
.chat-settings {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
}
.settings-sidebar {
border-right: 0;
border-bottom: 1px solid var(--settings-divider);
padding: 12px;
}
.settings-sidebar-header {
margin-bottom: 12px;
}
.settings-nav {
flex-direction: row;
}
.settings-nav-item {
flex: 1;
}
.settings-item:not(.settings-item-switch) {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.settings-content {
padding: 16px !important;
}
}
</style>
@@ -0,0 +1,56 @@
<template>
<div class="message-content-transition">
<Transition name="message-crossfade">
<div v-if="loading" key="loading" class="message-content-layer">
<ThinkingIndicator class="loading-message" />
</div>
<div v-else key="content" class="message-content-layer">
<slot />
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import ThinkingIndicator from "@/components/chat/ThinkingIndicator.vue";
defineProps<{ loading?: boolean }>();
</script>
<style scoped>
.message-content-transition {
display: grid;
}
.message-content-layer {
grid-area: 1 / 1;
align-self: start;
min-width: 0;
display: flow-root;
}
.loading-message {
margin: 6px 0;
}
.message-crossfade-enter-active,
.message-crossfade-leave-active {
transition: opacity 240ms ease;
}
.message-crossfade-leave-active {
pointer-events: none;
}
.message-crossfade-enter-from,
.message-crossfade-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.message-crossfade-enter-active,
.message-crossfade-leave-active {
transition: none;
}
}
</style>
+21 -23
View File
@@ -16,13 +16,7 @@
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
>
<v-avatar v-if="!isUserMessage(msg)" class="bot-avatar" size="48">
<v-progress-circular
v-if="isMessageStreaming(msgIndex)"
indeterminate
size="22"
width="2"
/>
<span v-else class="bot-avatar-symbol" aria-hidden="true">✦</span>
<span class="bot-avatar-symbol" aria-hidden="true">✦</span>
</v-avatar>
<div class="message-stack">
@@ -30,11 +24,7 @@
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
<span>{{ tm("message.loading") }}</span>
</div>
<template v-else>
<MessageContentTransition :loading="messageContent(msg).isLoading">
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
@@ -89,7 +79,10 @@
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
<img
:src="partUrl(part)"
:alt="part.filename || 'image'"
/>
</button>
<audio
@@ -110,7 +103,8 @@
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color': attachmentPresentation(part).color,
'--attachment-color':
attachmentPresentation(part).color,
}"
>
<v-icon
@@ -185,7 +179,7 @@
</template>
</template>
</template>
</template>
</MessageContentTransition>
</div>
<div v-if="showMessageMeta(msg, msgIndex)" class="message-meta">
@@ -275,6 +269,7 @@
</template>
<script setup lang="ts">
import MessageContentTransition from "@/components/chat/MessageContentTransition.vue";
import { computed, nextTick, reactive, ref } from "vue";
import axios from "axios";
import {
@@ -597,6 +592,7 @@ function formatDuration(seconds: number) {
<style scoped>
.message-list-root {
container: chat-messages / inline-size;
--chat-border: rgba(var(--v-border-color), 0.16);
--chat-muted: rgba(var(--v-theme-on-surface), 0.62);
width: 100%;
@@ -688,14 +684,6 @@ function formatDuration(seconds: number) {
white-space: pre-wrap;
}
.loading-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
color: var(--chat-muted);
}
.reply-quote {
width: 100%;
display: flex;
@@ -878,6 +866,16 @@ function formatDuration(seconds: number) {
object-fit: contain;
}
@container chat-messages (max-width: 600px) {
.message-row.from-bot .bot-avatar {
display: none;
}
.message-bubble.bot {
padding-inline: 0;
}
}
@media (max-width: 760px) {
.message-stack,
.from-user .message-stack {
@@ -1,6 +1,6 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="reasoning-sidebar">
<transition name="chat-panel">
<aside v-if="modelValue" class="reasoning-sidebar chat-side-panel">
<div class="reasoning-sidebar-header">
<div class="reasoning-sidebar-title">{{ reasoningTitle }}</div>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
@@ -22,6 +22,7 @@
</template>
<script setup lang="ts">
import "@/components/chat/chatPanelTransition.css";
import { computed } from "vue";
import {
reasoningActivityCounts,
@@ -59,10 +60,12 @@ function close() {
<style scoped>
.reasoning-sidebar {
width: 380px;
--chat-side-panel-width: 380px;
width: var(--chat-side-panel-width);
height: calc(100% - var(--chat-panel-top-offset, 0px));
margin-top: var(--chat-panel-top-offset, 0px);
border-left: 1px solid var(--chat-border, rgba(var(--v-theme-on-surface), 0.1));
border-left: 1px solid
var(--chat-border, rgba(var(--v-theme-on-surface), 0.1));
background: var(--chat-page-bg, rgb(var(--v-theme-surface)));
color: rgb(var(--v-theme-on-surface));
display: flex;
@@ -70,17 +73,6 @@ function close() {
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.reasoning-sidebar-header {
display: flex;
align-items: center;
@@ -124,7 +116,8 @@ function close() {
.reasoning-sidebar-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid var(--chat-border, rgba(var(--v-border-color), 0.12));
border-bottom: 1px solid
var(--chat-border, rgba(var(--v-border-color), 0.12));
}
.reasoning-sidebar-body {
@@ -8,21 +8,14 @@
@update:model-value="handleMenuToggle"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-refresh"
size="x-small"
variant="text"
/>
<v-btn v-bind="menuProps" icon size="x-small" variant="text">
<RotateCw :size="14" :stroke-width="2" />
</v-btn>
</template>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="emit('retry')"
>
<v-list-item class="styled-menu-item" rounded="md" @click="emit('retry')">
<template #prepend>
<v-icon size="18">mdi-refresh</v-icon>
<RotateCw :size="18" :stroke-width="2" />
</template>
<v-list-item-title>{{ tm("actions.retry") }}</v-list-item-title>
</v-list-item>
@@ -44,7 +37,9 @@
<template #prepend>
<v-icon size="18">mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm("actions.retryWithModel") }}</v-list-item-title>
<v-list-item-title>{{
tm("actions.retryWithModel")
}}</v-list-item-title>
<template #append>
<v-progress-circular
v-if="loadingProviders"
@@ -97,7 +92,9 @@
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
v-if="
formatContextLimit(provider, metadataForProvider(provider))
"
location="top"
max-width="320"
>
@@ -107,7 +104,12 @@
class="regenerate-model-context-badge"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
{{
formatContextLimit(
provider,
metadataForProvider(provider),
)
}}
</span>
</template>
<span>{{
@@ -123,7 +125,10 @@
</v-list-item-subtitle>
</v-list-item>
<div v-if="!loadingProviders && !providerConfigs.length" class="regenerate-empty">
<div
v-if="!loadingProviders && !providerConfigs.length"
class="regenerate-empty"
>
{{ tm("actions.noAvailableModels") }}
</div>
</v-list>
@@ -134,6 +139,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { RotateCw } from "@lucide/vue";
import { providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useModuleI18n } from "@/i18n/composables";
@@ -173,9 +179,10 @@ async function loadProviderConfigs(force = false) {
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
modelMetadata.value = (
response.data.model_metadata || {}
) as Record<string, ProviderModelMetadata>;
modelMetadata.value = (response.data.model_metadata || {}) as Record<
string,
ProviderModelMetadata
>;
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
@@ -29,11 +29,9 @@
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
{{ tm("message.loading") }}
</div>
<template v-else>
<MessageContentTransition
:loading="messageContent(msg).isLoading"
>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
@@ -161,7 +159,7 @@
</template>
</template>
</template>
</template>
</MessageContentTransition>
</div>
</div>
</div>
@@ -176,18 +174,16 @@
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending || initializing"
:enable-streaming="enableStreaming"
:enable-reasoning="enableReasoning"
show-settings
:is-recording="false"
:is-running="Boolean(currSessionId && isSessionRunning(currSessionId))"
:session-id="currSessionId || null"
:current-session="currentSession"
:config-id="configId || 'default'"
send-shortcut="enter"
:send-shortcut="sendShortcut"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="enableStreaming = !enableStreaming"
@toggle-reasoning="enableReasoning = !enableReasoning"
@open-settings="settingsOpen = true"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@@ -196,6 +192,14 @@
/>
</section>
<ChatSettingsDialog
v-model="settingsOpen"
v-model:enable-streaming="enableStreaming"
v-model:enable-reasoning="enableReasoning"
v-model:send-shortcut="sendShortcut"
v-model:transport-mode="transportMode"
/>
<v-overlay
v-model="imagePreview.visible"
class="image-preview-overlay"
@@ -208,6 +212,7 @@
</template>
<script setup lang="ts">
import MessageContentTransition from "@/components/chat/MessageContentTransition.vue";
import {
computed,
nextTick,
@@ -215,8 +220,10 @@ import {
onMounted,
reactive,
ref,
watch,
} from "vue";
import { chatApi, configRouteApi, fileApi } from "@/api/v1";
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
import { useDragUpload } from "@/composables/useDragUpload";
import {
@@ -259,6 +266,8 @@ const currSessionId = ref("");
const currentSession = ref<Session | null>(null);
const draft = ref("");
const initializing = ref(false);
const settingsOpen = ref(false);
const sendShortcut = ref<"enter" | "shift_enter">("enter");
const enableStreaming = ref(true);
const enableReasoning = ref(true);
const shouldStickToBottom = ref(true);
@@ -305,12 +314,16 @@ const {
},
});
const transportMode = computed<TransportMode>(() =>
const transportMode = ref<TransportMode>(
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
: "sse",
);
watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
onMounted(async () => {
await ensureSession();
inputRef.value?.focusInput();
@@ -620,8 +633,8 @@ function closeImage() {
.message-bubble.user {
padding: 12px 18px;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
border-radius: 16px;
background: rgba(var(--v-theme-primary), 0.16);
}
.message-bubble.bot {
@@ -633,7 +646,6 @@ function closeImage() {
white-space: pre-wrap;
}
.loading-message,
.tool-call-inline-status {
color: var(--standalone-muted);
}
@@ -0,0 +1,73 @@
<template>
<span class="thinking-indicator" role="status">
<slot>{{ tm(`message.loadingVariants.${loadingVariant}`) }}</slot>
</span>
</template>
<script setup lang="ts">
import { useModuleI18n } from "@/i18n/composables";
const { tm } = useModuleI18n("features/chat");
const loadingVariant = Math.floor(Math.random() * 10);
</script>
<style scoped>
.thinking-indicator {
display: block;
width: fit-content;
font-size: 1rem;
line-height: 1.7;
color: rgba(var(--v-theme-on-surface), 0.5);
}
@media (min-width: 761px) {
.thinking-indicator {
font-size: 0.9375rem;
line-height: 1.75;
}
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
.thinking-indicator {
background-image: linear-gradient(
110deg,
rgba(var(--v-theme-on-surface), 0.45) 40%,
rgba(var(--v-theme-on-surface), 0.95) 50%,
rgba(var(--v-theme-on-surface), 0.45) 60%
);
background-size: 250% 100%;
background-position: 100% 0;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: thinking-sweep 2.8s ease-in-out infinite;
}
}
@keyframes thinking-sweep {
0%,
15% {
background-position: 100% 0;
}
85%,
100% {
background-position: 0% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.thinking-indicator {
animation: none;
background: none;
color: rgba(var(--v-theme-on-surface), 0.6);
}
}
@media (forced-colors: active) {
.thinking-indicator {
animation: none;
background: none;
color: CanvasText;
}
}
</style>
+34 -30
View File
@@ -1,6 +1,6 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue && thread" class="thread-panel">
<transition name="chat-panel">
<aside v-if="modelValue && thread" class="thread-panel chat-side-panel">
<div class="thread-panel-header">
<div class="thread-panel-title">{{ tm("thread.title") }}</div>
<div class="thread-panel-actions">
@@ -55,6 +55,7 @@
</template>
<script setup lang="ts">
import "@/components/chat/chatPanelTransition.css";
import { nextTick, ref, watch } from "vue";
import { chatApi } from "@/api/v1";
import { fetchWithAuth } from "@/api/http";
@@ -154,17 +155,20 @@ async function send() {
const abort = new AbortController();
sending.value = true;
try {
const response = await fetchWithAuth(chatApi.sendThreadMessageUrl(props.thread.thread_id), {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetchWithAuth(
chatApi.sendThreadMessageUrl(props.thread.thread_id),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: [{ type: "plain", text }],
flags: buildChatRequestFlags(),
}),
signal: abort.signal,
},
body: JSON.stringify({
message: [{ type: "plain", text }],
flags: buildChatRequestFlags(),
}),
signal: abort.signal,
});
);
if (!response.ok || !response.body) {
throw new Error(`Thread request failed: ${response.status}`);
}
@@ -194,7 +198,10 @@ function normalizeRecord(record: any): ChatRecord {
content: {
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
message: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
reasoning: extractReasoningText(
normalizedMessage,
content.reasoning || "",
),
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
},
@@ -230,7 +237,11 @@ async function readSseStream(
}
}
function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload: any) {
function processPayload(
botRecord: ChatRecord,
userRecord: ChatRecord,
payload: any,
) {
const normalized =
payload?.ct === "chat"
? { ...payload, type: payload.type || payload.t }
@@ -327,7 +338,9 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
const storedFilename =
separatorIndex >= 0 ? rawFilename.slice(0, separatorIndex) : rawFilename;
const displayFilename =
separatorIndex >= 0 ? rawFilename.slice(separatorIndex + 1) : storedFilename;
separatorIndex >= 0
? rawFilename.slice(separatorIndex + 1)
: storedFilename;
const filename = displayFilename || storedFilename;
const mediaPart: MessagePart = { type, filename };
if (storedFilename && storedFilename !== filename) {
@@ -348,10 +361,12 @@ function scrollToBottom() {
<style scoped>
.thread-panel {
width: 380px;
--chat-side-panel-width: 380px;
width: var(--chat-side-panel-width);
height: calc(100% - var(--chat-panel-top-offset, 0px));
margin-top: var(--chat-panel-top-offset, 0px);
border-left: 1px solid var(--chat-border, rgba(var(--v-theme-on-surface), 0.1));
border-left: 1px solid
var(--chat-border, rgba(var(--v-theme-on-surface), 0.1));
background: var(--chat-page-bg, rgb(var(--v-theme-surface)));
color: rgb(var(--v-theme-on-surface));
display: flex;
@@ -359,17 +374,6 @@ function scrollToBottom() {
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.thread-panel-header {
display: flex;
align-items: center;
@@ -470,7 +474,8 @@ function scrollToBottom() {
.thread-panel-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid var(--chat-border, rgba(var(--v-border-color), 0.12));
border-bottom: 1px solid
var(--chat-border, rgba(var(--v-border-color), 0.12));
}
.thread-selected-text {
@@ -501,5 +506,4 @@ function scrollToBottom() {
flex-shrink: 0;
}
}
</style>
@@ -1,5 +1,6 @@
<template>
<MarkdownRender
class="chat-markdown"
custom-id="chat-message"
:content="threadedContent"
:is-dark="isDark"
@@ -9,13 +10,17 @@
:fade="false"
:typewriter="false"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:style="CHAT_MARKDOWN_HEADING_STYLE"
/>
</template>
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
import {
CHAT_MARKDOWN_HEADING_STYLE,
MARKDOWN_RENDER_MAX_LIVE_NODES,
} from "@/components/chat/markdownRenderConfig";
import type { ChatThread } from "@/composables/useMessages";
const props = defineProps<{
@@ -33,7 +38,8 @@ const emit = defineEmits<{
const isDarkRef = computed(() => props.isDark);
const refsByIndex = computed(() => {
const refs = props.refs && Array.isArray(props.refs.used) ? props.refs.used : [];
const refs =
props.refs && Array.isArray(props.refs.used) ? props.refs.used : [];
return refs.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
if (item.index != null) {
acc[String(item.index)] = item;
@@ -1,6 +1,6 @@
<template>
<transition name="workspace-panel-slide">
<aside v-if="modelValue" class="workspace-files-panel">
<transition name="chat-panel">
<aside v-if="modelValue" class="workspace-files-panel chat-side-panel">
<div class="workspace-toolbar">
<div class="workspace-filter">
<Search :size="17" />
@@ -158,7 +158,11 @@
><code>{{ fileContent }}</code></pre>
</section>
<v-dialog v-model="previewDialog" max-width="1000" width="calc(100% - 32px)">
<v-dialog
v-model="previewDialog"
max-width="1000"
width="calc(100% - 32px)"
>
<v-card class="workspace-dialog-preview">
<header class="workspace-dialog-preview-header">
<div class="workspace-preview-path" :title="selectedFilePath">
@@ -201,6 +205,7 @@
</template>
<script setup lang="ts">
import "@/components/chat/chatPanelTransition.css";
import { computed, ref, watch } from "vue";
import {
ChevronDown,
@@ -425,11 +430,7 @@ async function openEntry(entry: WorkspaceEntry) {
}
async function downloadSelectedFile() {
if (
!props.projectId ||
!selectedFilePath.value ||
fileDownloading.value
) {
if (!props.projectId || !selectedFilePath.value || fileDownloading.value) {
return;
}
fileDownloading.value = true;
@@ -462,7 +463,8 @@ function formatSize(size: number) {
<style scoped>
.workspace-files-panel {
width: clamp(340px, 29vw, 440px);
--chat-side-panel-width: clamp(340px, 29vw, 440px);
width: var(--chat-side-panel-width);
height: calc(100% - var(--chat-panel-top-offset, 0px));
margin-top: var(--chat-panel-top-offset, 0px);
border-left: 1px solid var(--chat-border, rgba(var(--v-border-color), 0.14));
@@ -474,19 +476,6 @@ function formatSize(size: number) {
min-width: 0;
}
.workspace-panel-slide-enter-active,
.workspace-panel-slide-leave-active {
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
.workspace-panel-slide-enter-from,
.workspace-panel-slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
.workspace-preview-header {
display: flex;
align-items: center;
@@ -0,0 +1,56 @@
.chat-markdown.markstream-vue {
--ms-text-body: 1rem;
--ms-leading-body: 1.7;
}
@media (min-width: 761px) {
.chat-markdown.markstream-vue {
--ms-text-body: 0.9375rem;
--ms-leading-body: 1.75;
}
}
.chat-markdown .markstream-vue {
--ms-text-body: inherit;
--ms-leading-body: inherit;
}
.chat-markdown table.table-node {
--ms-text-body: 0.875rem;
--ms-leading-body: 1.5;
font-size: 0.875rem;
line-height: 1.5;
}
.chat-markdown li.list-item {
font-size: var(--ms-text-body);
line-height: var(--ms-leading-body);
}
.chat-markdown li.list-item > p.paragraph-node {
margin: 0;
}
.chat-markdown li.list-item .paragraph-node:first-child {
margin-top: 0;
}
.chat-markdown .list-item::marker {
font-size: inherit;
line-height: inherit;
}
.chat-markdown blockquote.blockquote {
padding-block: 0;
}
.chat-markdown blockquote.blockquote p.paragraph-node {
margin-block: 0;
}
.chat-markdown hr.hr-node {
margin: 1rem 0;
border: 0;
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.1);
opacity: 1;
}
@@ -0,0 +1,33 @@
.chat-side-panel {
margin-inline-end: 0;
}
.chat-side-panel.chat-panel-enter-active,
.chat-side-panel.chat-panel-leave-active {
transition: margin-inline-end 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-side-panel.chat-panel-enter-from,
.chat-side-panel.chat-panel-leave-to {
margin-inline-end: calc(-1 * var(--chat-side-panel-width));
}
@media (max-width: 760px) {
.chat-side-panel.chat-panel-enter-active,
.chat-side-panel.chat-panel-leave-active {
transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-side-panel.chat-panel-enter-from,
.chat-side-panel.chat-panel-leave-to {
margin-inline-end: 0;
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-side-panel.chat-panel-enter-active,
.chat-side-panel.chat-panel-leave-active {
transition: none;
}
}
@@ -1 +1,24 @@
import "./chatMarkdown.css";
export const MARKDOWN_RENDER_MAX_LIVE_NODES = 320;
export const CHAT_MARKDOWN_HEADING_STYLE = {
"--ms-text-h1": "1.5rem",
"--ms-text-h2": "1.25rem",
"--ms-text-h3": "1.125rem",
"--ms-text-h4": "1rem",
"--ms-text-h5": "1rem",
"--ms-text-h6": "1rem",
"--ms-flow-heading-1-mt": "0",
"--ms-flow-heading-1-mb": "0.5em",
"--ms-flow-heading-2-mt": "1em",
"--ms-flow-heading-2-mb": "0.4em",
"--ms-flow-heading-3-mt": "0.8em",
"--ms-flow-heading-3-mb": "0.3em",
"--ms-flow-heading-4-mt": "0.6em",
"--ms-flow-heading-4-mb": "0.2em",
"--ms-flow-heading-5-mt": "0.4em",
"--ms-flow-heading-5-mb": "0.15em",
"--ms-flow-heading-6-mt": "0.4em",
"--ms-flow-heading-6-mb": "0.15em",
};
@@ -1,6 +1,7 @@
<template>
<div class="markdown-content">
<MarkdownRender
class="chat-markdown"
custom-id="chat-message"
:content="content"
:custom-html-tags="customHtmlTags"
@@ -10,6 +11,7 @@
:fade="false"
:typewriter="false"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:style="CHAT_MARKDOWN_HEADING_STYLE"
/>
</div>
</template>
@@ -17,7 +19,10 @@
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
import {
CHAT_MARKDOWN_HEADING_STYLE,
MARKDOWN_RENDER_MAX_LIVE_NODES,
} from "@/components/chat/markdownRenderConfig";
const props = defineProps<{
content: string;
@@ -7,15 +7,21 @@
@click="handlePrimaryAction"
>
<span class="reasoning-title">
{{ reasoningTitle }}
<ThinkingIndicator v-if="isStreaming && !hasNonReasoningContent">
{{ reasoningTitle }}
</ThinkingIndicator>
<template v-else>{{ reasoningTitle }}</template>
</span>
<v-icon
size="22"
<ChevronRight
:size="20"
:stroke-width="1.75"
aria-hidden="true"
class="reasoning-icon"
:class="{ 'rotate-90': !openInSidebar && isExpanded }"
>
mdi-chevron-right
</v-icon>
:class="{
'rotate-90': !openInSidebar && isExpanded,
'reasoning-icon--thinking': isStreaming && !hasNonReasoningContent,
}"
/>
</button>
<div
@@ -31,7 +37,11 @@
</div>
<transition :name="previewTransitionName" mode="out-in">
<div v-if="showStreamingPreview" :key="previewKey" class="reasoning-preview">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
{{ previewText }}
</div>
</transition>
@@ -40,12 +50,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { ChevronRight } from "@lucide/vue";
import {
reasoningActivityCounts,
reasoningActivityTitle,
type MessagePart,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ThinkingIndicator from "@/components/chat/ThinkingIndicator.vue";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
const props = defineProps<{
@@ -208,6 +220,7 @@ onBeforeUnmount(() => {
}
.reasoning-header {
width: fit-content;
max-width: 100%;
border: 0;
padding: 0;
@@ -215,13 +228,22 @@ onBeforeUnmount(() => {
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
display: flex;
align-items: center;
gap: 8px;
font: inherit;
font-size: 1rem;
line-height: 1.7;
text-align: left;
}
@media (min-width: 761px) {
.reasoning-header {
font-size: 0.9375rem;
line-height: 1.75;
}
}
.reasoning-header:hover {
color: rgba(var(--v-theme-on-surface), 0.88);
}
@@ -233,6 +255,22 @@ onBeforeUnmount(() => {
align-self: center;
}
.reasoning-icon--thinking {
color: rgba(var(--v-theme-on-surface), 0.45);
}
@media (prefers-reduced-motion: reduce) {
.reasoning-icon--thinking {
color: rgba(var(--v-theme-on-surface), 0.6);
}
}
@media (forced-colors: active) {
.reasoning-icon--thinking {
color: CanvasText;
}
}
.reasoning-title {
min-width: 0;
overflow: hidden;
@@ -21,13 +21,14 @@
<MarkdownRender
v-if="entry.kind === 'think'"
:content="entry.think || ''"
class="reasoning-text markdown-content"
class="chat-markdown reasoning-text markdown-content"
:final="!isStreaming"
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="false"
:typewriter="false"
:is-dark="isDark"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:style="CHAT_MARKDOWN_HEADING_STYLE"
/>
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
@@ -48,11 +49,7 @@
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="entry.tool"
:is-dark="isDark"
/>
<ToolCallCard v-else :tool-call="entry.tool" :is-dark="isDark" />
</div>
</div>
</div>
@@ -62,7 +59,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownRender } from "markstream-vue";
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
import {
CHAT_MARKDOWN_HEADING_STYLE,
MARKDOWN_RENDER_MAX_LIVE_NODES,
} from "@/components/chat/markdownRenderConfig";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
@@ -123,7 +123,9 @@ const timelineEntries = computed<TimelineEntry[]>(() => {
part.tool_calls.forEach((tool, toolIndex) => {
const normalizedTool = normalizeToolCall(tool);
entries.push({
key: `tool-${String(tool.id || tool.name || `${partIndex}-${toolIndex}`)}`,
key: `tool-${String(
tool.id || tool.name || `${partIndex}-${toolIndex}`,
)}`,
kind: "tool_call",
title: tm("reasoning.toolUsed"),
tool: normalizedTool,
@@ -136,7 +138,9 @@ const timelineEntries = computed<TimelineEntry[]>(() => {
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args ?? normalized.arguments ?? {});
normalized.args = parseJsonSafe(
normalized.args ?? normalized.arguments ?? {},
);
normalized.result = parseJsonSafe(normalized.result);
normalized.ts = normalized.ts ?? Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
@@ -1,6 +1,6 @@
<template>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<transition name="chat-panel">
<div v-if="isOpen" class="refs-sidebar chat-side-panel">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm("refs.title") }}</h3>
<v-btn
@@ -44,6 +44,7 @@
</template>
<script>
import "@/components/chat/chatPanelTransition.css";
import { useModuleI18n } from "@/i18n/composables";
export default {
@@ -122,7 +123,8 @@ export default {
<style scoped>
.refs-sidebar {
width: 360px;
--chat-side-panel-width: 360px;
width: var(--chat-side-panel-width);
height: calc(100% - var(--chat-panel-top-offset, 0px));
margin-top: var(--chat-panel-top-offset, 0px);
background: var(--chat-page-bg, rgb(var(--v-theme-surface)));
@@ -133,21 +135,6 @@ export default {
color: rgb(var(--v-theme-on-surface));
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
@@ -1,6 +1,18 @@
{
"title": "Let's Chat!",
"subtitle": "Chat with AI Assistant",
"settings": {
"general": "General",
"chat": "Chat",
"appearance": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System",
"streaming": "Streaming output",
"streamingHint": "Show responses as they are generated. Applies only to ChatUI.",
"reasoning": "Show reasoning",
"reasoningHint": "Display reasoning returned by the model. Applies only to ChatUI."
},
"input": {
"placeholder": "Ask anything...",
"projectPlaceholder": "What should we work on next?",
@@ -18,7 +30,19 @@
"assistant": "Assistant",
"system": "System",
"error": "Error Message",
"loading": "Thinking..."
"loading": "Thinking...",
"loadingVariants": [
"(。•̀ᴗ-)✧ Thinking...",
"(・_・ヾ Pondering...",
"(๑•̀ㅂ•́)و Gathering my thoughts...",
"(。・ω・。) Looking for inspiration...",
"( •̀ ω •́ )✧ Thinking it through...",
"(つ•̀ω•́)つ Finding the right words...",
"(・・。)ゞ Connecting the dots...",
"(ง •_•)ง Working it out...",
"( ˘ω˘ ) Exploring possibilities...",
"(ノ◕ヮ◕)ノ Brewing an answer..."
]
},
"voice": {
"start": "Start Recording",
@@ -38,6 +62,7 @@
"examples": "Example Questions"
},
"actions": {
"scrollToBottom": "Scroll to bottom",
"copy": "Copy",
"regenerate": "Regenerate",
"retry": "Retry",
@@ -1,6 +1,18 @@
{
"title": "チャットしよう!",
"subtitle": "AI アシスタントと会話",
"settings": {
"general": "一般",
"chat": "チャット",
"appearance": "外観",
"light": "ライト",
"dark": "ダーク",
"system": "システムに合わせる",
"streaming": "ストリーミング出力",
"streamingHint": "生成中の回答を順次表示します。ChatUI にのみ適用されます。",
"reasoning": "思考内容を表示",
"reasoningHint": "モデルが返した思考過程を表示します。ChatUI にのみ適用されます。"
},
"input": {
"placeholder": "何でも聞いてください",
"projectPlaceholder": "次に何をしますか?",
@@ -18,7 +30,19 @@
"assistant": "アシスタント",
"system": "システム",
"error": "エラーメッセージ",
"loading": "考え中…"
"loading": "考え中…",
"loadingVariants": [
"(。•̀ᴗ-)✧ 考え中...",
"(・_・ヾ じっくり検討中...",
"(๑•̀ㅂ•́)و 思考を整理中...",
"(。・ω・。) ひらめきを探し中...",
"( •̀ ω •́ )✧ しっかり分析中...",
"(つ•̀ω•́)つ 言葉を選び中...",
"(・・。)ゞ 手がかりを整理中...",
"(ง •_•)ง 解決策を模索中...",
"( ˘ω˘ ) 可能性を検討中...",
"(ノ◕ヮ◕)ノ 答えを準備中..."
]
},
"voice": {
"start": "録音を開始",
@@ -38,6 +62,7 @@
"examples": "質問例"
},
"actions": {
"scrollToBottom": "一番下へ移動",
"copy": "コピー",
"regenerate": "再生成",
"retry": "再試行",
@@ -1,6 +1,18 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"settings": {
"general": "Общие",
"chat": "Чат",
"appearance": "Оформление",
"light": "Светлое",
"dark": "Тёмное",
"system": "Как в системе",
"streaming": "Потоковый вывод",
"streamingHint": "Показывать ответ по мере его генерации. Только для ChatUI.",
"reasoning": "Показывать рассуждения",
"reasoningHint": "Показывать рассуждения, возвращённые моделью. Только для ChatUI."
},
"input": {
"placeholder": "Спросите что угодно...",
"projectPlaceholder": "Что вы хотите сделать дальше?",
@@ -18,7 +30,19 @@
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
"loading": "Думаю...",
"loadingVariants": [
"(。•̀ᴗ-)✧ Думаю...",
"(・_・ヾ Размышляю...",
"(๑•̀ㅂ•́)و Собираюсь с мыслями...",
"(。・ω・。) Ищу вдохновение...",
"( •̀ ω •́ )✧ Анализирую...",
"(つ•̀ω•́)つ Подбираю слова...",
"(・・。)ゞ Связываю всё воедино...",
"(ง •_•)ง Ищу решение...",
"( ˘ω˘ ) Обдумываю варианты...",
"(ノ◕ヮ◕)ノ Готовлю ответ..."
]
},
"voice": {
"start": "Начать запись",
@@ -38,6 +62,7 @@
"examples": "Примеры вопросов"
},
"actions": {
"scrollToBottom": "Прокрутить вниз",
"copy": "Копировать",
"regenerate": "Перегенерировать",
"retry": "Повторить",
@@ -1,6 +1,18 @@
{
"title": "聊天吧!",
"subtitle": "与AI助手进行对话",
"settings": {
"general": "通用",
"chat": "聊天",
"appearance": "外观",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"streaming": "流式输出",
"streamingHint": "回复生成时逐步显示内容,仅影响 ChatUI。",
"reasoning": "显示思考内容",
"reasoningHint": "显示模型返回的思考过程,仅影响 ChatUI。"
},
"input": {
"placeholder": "想问什么都可以",
"projectPlaceholder": "接下来做点什么?",
@@ -18,7 +30,19 @@
"assistant": "助手",
"system": "系统",
"error": "错误消息",
"loading": "思考中..."
"loading": "思考中...",
"loadingVariants": [
"(。•̀ᴗ-)✧ 思考中...",
"(・_・ヾ 琢磨中...",
"(๑•̀ㅂ•́)و 整理思路中...",
"(。・ω・。) 寻找灵感中...",
"( •̀ ω •́ )✧ 认真分析中...",
"(つ•̀ω•́)つ 组织语言中...",
"(・・。)ゞ 捋捋头绪中...",
"(ง •_•)ง 努力想办法中...",
"( ˘ω˘ ) 脑内推演中...",
"(ノ◕ヮ◕)ノ 酝酿答案中..."
]
},
"voice": {
"start": "开始录音",
@@ -38,6 +62,7 @@
"examples": "示例问题"
},
"actions": {
"scrollToBottom": "回到底部",
"copy": "复制",
"regenerate": "重新生成",
"retry": "重试",