mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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": "重试",
|
||||
|
||||
Reference in New Issue
Block a user