Files
AstrBot/dashboard/src/components/chat/Chat.vue
2026-04-25 15:04:48 +08:00

1574 lines
41 KiB
Vue

<template>
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
>
<v-navigation-drawer
v-model="chatSidebarDrawer"
class="chat-sidebar"
:class="{ collapsed: isSidebarCollapsed }"
:permanent="lgAndUp"
:temporary="!lgAndUp"
:rail="lgAndUp && sidebarCollapsed"
:width="280"
:rail-width="68"
location="left"
floating
>
<div class="sidebar-top">
<div v-if="lgAndUp" class="brand-row">
<v-btn
icon
size="small"
variant="text"
class="sidebar-toggle"
@click="sidebarCollapsed = !sidebarCollapsed"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'chevron-collapsed': isSidebarCollapsed }"
>
mdi-chevron-left
</v-icon>
</v-btn>
</div>
<v-btn
v-if="canManageProviders"
class="new-chat-btn sidebar-provider-btn"
:class="{
'icon-only': isSidebarCollapsed,
'sidebar-workspace-btn--active': isProviderWorkspace,
}"
variant="text"
:icon="isSidebarCollapsed"
@click="openProviderWorkspace"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-creation</v-icon
>
<span v-if="!isSidebarCollapsed">{{ tm("actions.providerConfig") }}</span>
</v-btn>
<v-btn
class="new-chat-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
@click="startNewChat"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-square-edit-outline</v-icon
>
<span v-if="!isSidebarCollapsed">{{ tm("actions.newChat") }}</span>
</v-btn>
<ProjectList
v-if="!isSidebarCollapsed"
:projects="projects"
:selected-project-id="selectedProjectId"
@create-project="openCreateProjectDialog"
@edit-project="openEditProjectDialog"
@delete-project="handleDeleteProject"
@select-project="selectProject"
/>
</div>
<div v-if="!isSidebarCollapsed" class="session-list">
<div
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@keydown.enter="selectSession(session.session_id)"
@keydown.space.prevent="selectSession(session.session_id)"
>
<span v-if="!isSidebarCollapsed" class="session-title">{{
sessionTitle(session)
}}</span>
<div class="session-actions" @click.stop>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('conversation.editDisplayName')"
@click="editSidebarSessionTitle(session)"
/>
<v-btn
icon="mdi-delete-outline"
size="x-small"
variant="text"
class="session-action-btn"
:title="tm('actions.deleteChat')"
@click="deleteSidebarSession(session)"
/>
</div>
<v-progress-circular
v-if="isSessionRunning(session.session_id)"
class="session-progress"
indeterminate
size="16"
width="2"
/>
</div>
<div
v-if="!isSidebarCollapsed && !sessions.length && !loadingSessions"
class="empty-sessions"
>
{{ tm("conversation.noHistory") }}
</div>
</div>
<div class="sidebar-footer">
<v-btn
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
@click="chatSettingsDialogOpen = true"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-cog-outline</v-icon
>
<span v-if="!isSidebarCollapsed">{{ t("core.common.settings") }}</span>
</v-btn>
</div>
</v-navigation-drawer>
<ChatSettingsDialog
v-model="chatSettingsDialogOpen"
v-model:transport-mode="transportMode"
/>
<main
class="chat-main"
:class="{
'empty-chat': !isProviderWorkspace &&
!selectedProject && !loadingMessages && !activeMessages.length,
}"
>
<section v-if="isProviderWorkspace" class="provider-workspace-shell">
<ProviderChatCompletionPanel
class="provider-workspace-page"
:show-border="false"
/>
</section>
<ProjectView
v-else-if="selectedProject"
:project="selectedProject"
:sessions="projectSessions"
@select-session="selectProjectSession"
@edit-session-title="editProjectSessionTitle"
@delete-session="deleteProjectSession"
>
<section class="project-composer-shell">
<ChatInput
ref="inputRef"
v-model:prompt="draft"
:staged-images-url="stagedImagesUrl"
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending"
:enable-streaming="enableStreaming"
:is-recording="isRecording"
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
:send-shortcut="sendShortcut"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="toggleStreaming"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@start-recording="startRecording"
@stop-recording="stopRecording"
@paste-image="handlePaste"
@file-select="handleFilesSelected"
@clear-reply="replyTarget = null"
/>
</section>
</ProjectView>
<template v-else>
<section
ref="messagesContainer"
class="messages-panel"
@scroll="handleMessagesScroll"
>
<div v-if="loadingMessages" class="center-state">
<v-progress-circular indeterminate size="32" width="3" />
</div>
<div v-else-if="sessionProject" class="session-project-breadcrumb">
<span>{{ sessionProject.title }}</span>
<v-icon size="16">mdi-chevron-right</v-icon>
<span>{{ currentSessionTitle }}</span>
</div>
<div v-else-if="!activeMessages.length" class="welcome-state">
<div class="welcome-title">{{ tm("welcome.title") }}</div>
</div>
<div
v-if="!loadingMessages && activeMessages.length"
class="messages-list-shell"
>
<ChatMessageList
v-model:edit-draft="messageEditDraft"
:messages="activeMessages"
:is-dark="isDark"
:is-streaming="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:enable-edit="
!Boolean(currSessionId && isSessionRunning(currSessionId))
"
enable-regenerate
enable-thread-selection
:manage-refs-sidebar="false"
:editing-message-id="editingMessage?.id || null"
:saving-edit="savingMessageEdit"
@open-edit="openMessageEdit"
@cancel-edit="cancelMessageEdit"
@save-edit="saveMessageEdit"
@regenerate="handleRegenerateMessage"
@regenerate-with-model="handleRegenerateMessage"
@select-bot-text="handleBotTextSelection"
@open-thread="openThreadPanel"
@open-reasoning="openReasoningPanel"
@open-refs="openRefsSidebar"
/>
</div>
</section>
<section class="composer-shell">
<ChatInput
ref="inputRef"
v-model:prompt="draft"
:staged-images-url="stagedImagesUrl"
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending"
:enable-streaming="enableStreaming"
:is-recording="isRecording"
:is-running="
Boolean(currSessionId && isSessionRunning(currSessionId))
"
:session-id="currSessionId || null"
:current-session="currentSession"
:reply-to="chatInputReplyTarget"
:send-shortcut="sendShortcut"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="toggleStreaming"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@start-recording="startRecording"
@stop-recording="stopRecording"
@paste-image="handlePaste"
@file-select="handleFilesSelected"
@clear-reply="replyTarget = null"
/>
</section>
</template>
</main>
<div
v-if="threadSelection.visible"
class="thread-selection-action"
:style="{
left: `${threadSelection.left}px`,
top: `${threadSelection.top}px`,
}"
>
<button
class="thread-selection-button"
type="button"
@click="createThreadFromSelection"
>
{{ tm("thread.askInThread") }}
</button>
</div>
<ProjectDialog
v-model="projectDialogOpen"
:project="editingProject"
@save="saveProject"
/>
<v-dialog v-model="sessionTitleDialogOpen" max-width="420">
<v-card>
<v-card-title class="text-h6">
{{ tm("conversation.editDisplayName") }}
</v-card-title>
<v-card-text>
<v-text-field
v-model="sessionTitleDraft"
:label="tm('conversation.displayName')"
variant="outlined"
density="comfortable"
hide-details
autofocus
@keydown.enter="saveSessionTitleDialog"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="sessionTitleDialogOpen = false">
{{ t("core.common.cancel") }}
</v-btn>
<v-btn
color="primary"
:loading="savingSessionTitle"
@click="saveSessionTitleDialog"
>
{{ t("core.common.save") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ThreadPanel
v-model="threadPanelOpen"
:thread="activeThread"
:is-dark="isDark"
:deleting="deletingThread"
@delete="deleteThread"
/>
<ReasoningSidebar
v-model="reasoningPanelOpen"
:parts="activeReasoningParts"
:is-dark="isDark"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
</div>
</template>
<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
reactive,
ref,
watch,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
import ProjectList, { type Project } from "@/components/chat/ProjectList.vue";
import ProjectView from "@/components/chat/ProjectView.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue";
import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue";
import ThreadPanel from "@/components/chat/ThreadPanel.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import { useSessions, type Session } from "@/composables/useSessions";
import {
messageBlocks as buildMessageBlocks,
useMessages,
type ChatRecord,
type ChatThread,
type MessagePart,
type TransportMode,
} from "@/composables/useMessages";
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useProjects } from "@/composables/useProjects";
import { useAuthStore } from "@/stores/auth";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
chatboxMode: false,
active: true,
});
const route = useRoute();
const router = useRouter();
const { lgAndUp } = useDisplay();
const customizer = useCustomizerStore();
const authStore = useAuthStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const toast = useToast();
const {
sessions,
currSessionId,
getSessions,
newSession,
newChat,
deleteSession,
updateSessionTitle,
} = useSessions(props.chatboxMode);
const {
projects,
selectedProjectId,
getProjects,
createProject,
updateProject,
deleteProject: deleteProjectById,
addSessionToProject,
getProjectSessions,
} = useProjects();
const {
stagedFiles,
stagedImagesUrl,
stagedAudioUrl,
stagedNonImageFiles,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
type WorkspaceView = "chat" | "providers";
const sidebarCollapsed = ref(false);
const activeWorkspace = ref<WorkspaceView>("chat");
const projectDialogOpen = ref(false);
const chatSettingsDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
const sessionTitleDialogOpen = ref(false);
const sessionTitleDraft = ref("");
const editingSessionTitleId = ref("");
const refreshProjectSessionsAfterTitleSave = ref(false);
const savingSessionTitle = ref(false);
const messageEditDraft = ref("");
const editingMessage = ref<ChatRecord | null>(null);
const savingMessageEdit = ref(false);
const projectSessions = ref<Session[]>([]);
const loadingSessions = ref(false);
const draft = ref("");
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const shouldStickToBottom = ref(true);
const replyTarget = ref<ChatRecord | null>(null);
const threadPanelOpen = ref(false);
const activeThread = ref<ChatThread | null>(null);
const reasoningPanelOpen = ref(false);
const activeReasoningTarget = ref<{
message: ChatRecord;
blockIndex: number;
} | null>(null);
const deletingThread = ref(false);
const refsSidebarOpen = ref(false);
const selectedRefs = ref<Record<string, unknown> | null>(null);
const threadSelection = reactive<{
visible: boolean;
left: number;
top: number;
message: ChatRecord | null;
selectedText: string;
}>({
visible: false,
left: 0,
top: 0,
message: null,
selectedText: "",
});
const enableStreaming = ref(true);
const isRecording = ref(false);
const sendShortcut = ref<"enter" | "shift_enter">("enter");
const chatSidebarDrawer = computed({
get: () => lgAndUp.value || customizer.chatSidebarOpen,
set: (value: boolean) => {
if (!lgAndUp.value) {
customizer.SET_CHAT_SIDEBAR(value);
}
},
});
const isSidebarCollapsed = computed(() =>
lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen,
);
const isProviderWorkspace = computed(
() => activeWorkspace.value === "providers",
);
const canManageProviders = computed(() => authStore.canManageProviders());
const activeReasoningParts = computed<MessagePart[]>(() => {
if (!activeReasoningTarget.value) return [];
const blocks = buildMessageBlocks(
activeReasoningTarget.value.message.content || { type: "bot", message: [] },
);
const block = blocks[activeReasoningTarget.value.blockIndex];
return block?.kind === "thinking" ? block.parts : [];
});
watch(reasoningPanelOpen, (open) => {
if (!open) {
activeReasoningTarget.value = null;
}
});
const {
loadingMessages,
sending,
loadedSessions,
sessionProjects,
activeMessages,
isSessionRunning,
isUserMessage,
messageParts,
loadSessionMessages,
createLocalExchange,
sendMessageStream,
editMessage,
continueEditedMessage,
regenerateMessage,
stopSession,
} = useMessages({
currentSessionId: currSessionId,
onSessionsChanged: getSessions,
onStreamUpdate: (sessionId) => {
if (sessionId === currSessionId.value && shouldStickToBottom.value) {
scrollToBottom();
}
},
});
const transportMode = ref<TransportMode>(
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
: "sse",
);
watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const canSend = computed(
() =>
Boolean(draft.value.trim() || stagedFiles.value.length) && !sending.value,
);
const currentSession = computed(
() =>
sessions.value.find(
(session) => session.session_id === currSessionId.value,
) ||
projectSessions.value.find(
(session) => session.session_id === currSessionId.value,
) ||
null,
);
const sessionProject = computed(() =>
currSessionId.value ? sessionProjects[currSessionId.value] : null,
);
const currentSessionTitle = computed(() =>
currentSession.value ? sessionTitle(currentSession.value) : "",
);
const selectedProject = computed(
() =>
projects.value.find(
(project) => project.project_id === selectedProjectId.value,
) || null,
);
const chatInputReplyTarget = computed(() =>
replyTarget.value?.id == null
? null
: {
messageId: replyTarget.value.id,
selectedText: replyPreview(replyTarget.value.id, ""),
},
);
provide("isDark", isDark);
onMounted(async () => {
loadingSessions.value = true;
try {
await Promise.all([getSessions(), getProjects()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
} else if (routeSessionId) {
await selectSession(routeSessionId, false);
}
} finally {
loadingSessions.value = false;
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
});
watch(
() => route.params.conversationId,
async () => {
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
return;
}
if (routeSessionId && routeSessionId !== currSessionId.value) {
showChatWorkspace();
selectedProjectId.value = null;
await selectSession(routeSessionId, false);
} else if (!routeSessionId && currSessionId.value) {
showChatWorkspace();
currSessionId.value = "";
}
},
);
watch(activeMessages, () => {
if (shouldStickToBottom.value) {
scrollToBottom();
}
});
function getRouteSessionId() {
const raw = route.params.conversationId;
return Array.isArray(raw) ? raw[0] : raw || "";
}
function basePath() {
return props.chatboxMode ? "/chatbox" : "/chat";
}
function closeMobileSidebar() {
if (!lgAndUp.value) {
customizer.SET_CHAT_SIDEBAR(false);
}
}
function closeSecondaryPanels() {
threadSelection.visible = false;
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
}
function showChatWorkspace() {
activeWorkspace.value = "chat";
}
async function openProviderWorkspace() {
if (!canManageProviders.value) {
return;
}
closeSecondaryPanels();
activeWorkspace.value = "providers";
const targetPath = `${basePath()}/models`;
if (route.path !== targetPath) {
await router.push(targetPath);
}
closeMobileSidebar();
}
function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("conversation.newConversation");
}
async function startNewChat() {
showChatWorkspace();
selectedProjectId.value = null;
replyTarget.value = null;
newChat();
closeMobileSidebar();
}
function openCreateProjectDialog() {
editingProject.value = null;
projectDialogOpen.value = true;
}
function openEditProjectDialog(project: Project) {
editingProject.value = project;
projectDialogOpen.value = true;
}
async function selectProject(projectId: string) {
showChatWorkspace();
selectedProjectId.value = projectId;
currSessionId.value = "";
replyTarget.value = null;
await router.push(basePath());
await loadProjectSessions(projectId);
closeMobileSidebar();
}
async function loadProjectSessions(projectId = selectedProjectId.value) {
if (!projectId) {
projectSessions.value = [];
return;
}
projectSessions.value = await getProjectSessions(projectId);
}
async function handleDeleteProject(projectId: string) {
await deleteProjectById(projectId);
if (selectedProjectId.value === projectId) {
selectedProjectId.value = null;
projectSessions.value = [];
}
}
function openSessionTitleDialog(
sessionId: string,
title: string,
refreshProjectSessions = false,
) {
editingSessionTitleId.value = sessionId;
sessionTitleDraft.value = title;
refreshProjectSessionsAfterTitleSave.value = refreshProjectSessions;
sessionTitleDialogOpen.value = true;
}
async function saveSessionTitleDialog() {
if (!editingSessionTitleId.value) return;
savingSessionTitle.value = true;
try {
const sessionId = editingSessionTitleId.value;
const displayName = sessionTitleDraft.value.trim();
await axios.post("/api/chat/update_session_display_name", {
session_id: sessionId,
display_name: displayName,
});
updateSessionTitle(sessionId, displayName);
const projectSession = projectSessions.value.find(
(session) => session.session_id === sessionId,
);
if (projectSession) {
projectSession.display_name = displayName;
}
if (refreshProjectSessionsAfterTitleSave.value) {
await loadProjectSessions();
}
sessionTitleDialogOpen.value = false;
} finally {
savingSessionTitle.value = false;
}
}
function editSidebarSessionTitle(session: Session) {
openSessionTitleDialog(session.session_id, session.display_name || "");
}
async function deleteSidebarSession(session: Session) {
const title = sessionTitle(session);
const message = tm("conversation.confirmDelete", { name: title });
if (!(await askForConfirmation(message, confirmDialog))) return;
const wasCurrent = currSessionId.value === session.session_id;
await deleteSession(session.session_id);
if (wasCurrent) {
selectedProjectId.value = null;
await router.push(basePath());
}
}
async function selectProjectSession(sessionId: string) {
selectedProjectId.value = null;
await selectSession(sessionId);
}
async function editProjectSessionTitle(sessionId: string, title: string) {
openSessionTitleDialog(sessionId, title, true);
}
async function deleteProjectSession(sessionId: string) {
await deleteSession(sessionId);
await loadProjectSessions();
}
async function saveProject(formData: ProjectFormData, projectId?: string) {
if (projectId) {
await updateProject(
projectId,
formData.title,
formData.emoji,
formData.description,
);
return;
}
await createProject(formData.title, formData.emoji, formData.description);
}
async function selectSession(sessionId: string, pushRoute = true) {
showChatWorkspace();
selectedProjectId.value = null;
currSessionId.value = sessionId;
replyTarget.value = null;
if (pushRoute && route.path !== `${basePath()}/${sessionId}`) {
await router.push(`${basePath()}/${sessionId}`);
}
if (!loadedSessions[sessionId]) {
await loadSessionMessages(sessionId);
}
scrollToBottom();
closeMobileSidebar();
}
async function sendCurrentMessage() {
if (!canSend.value) return;
sending.value = true;
try {
let sessionId = currSessionId.value;
const targetProjectId = selectedProjectId.value;
const targetProject = selectedProject.value;
if (!sessionId) {
sessionId = await newSession();
if (targetProjectId) {
await addSessionToProject(sessionId, targetProjectId);
sessionProjects[sessionId] = targetProject
? {
project_id: targetProject.project_id,
title: targetProject.title,
emoji: targetProject.emoji,
}
: null;
await loadProjectSessions(targetProjectId);
selectedProjectId.value = null;
}
}
const text = draft.value.trim();
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
const outgoingParts = buildOutgoingParts(text);
const selection = inputRef.value?.getCurrentSelection();
const { userRecord, botRecord } = createLocalExchange({
sessionId,
messageId,
parts: outgoingParts,
});
updateTitleFromText(sessionId, text);
draft.value = "";
replyTarget.value = null;
clearStaged({ revokeUrls: false });
scrollToBottom();
sendMessageStream({
sessionId,
messageId,
parts: outgoingParts,
transport: transportMode.value,
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
userRecord,
botRecord,
});
} catch (error) {
console.error("Failed to send message:", error);
} finally {
sending.value = false;
}
}
function buildOutgoingParts(text: string): MessagePart[] {
const parts: MessagePart[] = [];
if (replyTarget.value?.id != null) {
parts.push({
type: "reply",
message_id: replyTarget.value.id,
selected_text: "",
});
}
if (text) {
parts.push({ type: "plain", text });
}
stagedFiles.value.forEach((file) => {
parts.push({
type: file.type,
attachment_id: file.attachment_id,
filename: file.filename,
embedded_url: file.url,
});
});
return parts;
}
function updateTitleFromText(sessionId: string, text: string) {
const session = sessions.value.find((item) => item.session_id === sessionId);
if (!session || session.display_name || !text) return;
updateSessionTitle(sessionId, text.slice(0, 40));
}
function replyPreview(messageId?: string | number, fallback?: string) {
if (fallback) return truncate(fallback, 80);
const found = activeMessages.value.find(
(message) => String(message.id) === String(messageId),
);
const text = found ? plainTextFromMessage(found) : "";
return text ? truncate(text, 80) : tm("reply.replyTo");
}
function plainTextFromMessage(message: ChatRecord) {
return messageParts(message)
.filter((part) => part.type === "plain" && part.text)
.map((part) => part.text)
.join("\n");
}
function truncate(value: string, max: number) {
return value.length > max ? `${value.slice(0, max)}...` : value;
}
function scrollToMessage(messageId?: string | number) {
if (!messageId) return;
const index = activeMessages.value.findIndex(
(message) => String(message.id) === String(messageId),
);
if (index < 0) return;
const rows = messagesContainer.value?.querySelectorAll(".message-row");
rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function openMessageEdit(message: ChatRecord) {
messageEditDraft.value = plainTextFromMessage(message);
editingMessage.value = message;
nextTick(() => scrollToMessage(message.id));
}
function cancelMessageEdit() {
editingMessage.value = null;
messageEditDraft.value = "";
}
async function saveMessageEdit() {
if (!currSessionId.value || !editingMessage.value) return;
savingMessageEdit.value = true;
try {
const target = editingMessage.value;
const result = await editMessage(
currSessionId.value,
target,
messageEditDraft.value,
);
cancelMessageEdit();
if (result.needsRegenerate && result.truncatedAfterMessage) {
const selection = inputRef.value?.getCurrentSelection();
continueEditedMessage({
sessionId: currSessionId.value,
sourceRecord: target,
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
});
scrollToBottom();
} else if (result.needsRegenerate) {
const index = activeMessages.value.findIndex(
(message) => String(message.id) === String(target.id),
);
const nextBot = activeMessages.value
.slice(index + 1)
.find((message) => !isUserMessage(message));
if (nextBot) {
await handleRegenerateMessage(nextBot);
}
}
} catch (error) {
console.error("Failed to edit message:", error);
} finally {
savingMessageEdit.value = false;
}
}
async function handleRegenerateMessage(
message: ChatRecord,
selection?: RegenerateModelSelection,
) {
if (!currSessionId.value || isUserMessage(message)) return;
message.threads = [];
await regenerateMessage(
currSessionId.value,
message,
selection?.providerId || "",
selection?.modelName || "",
);
}
function handleBotTextSelection(event: MouseEvent, message: ChatRecord) {
if (message.id == null || String(message.id).startsWith("local-")) return;
const container = event.currentTarget as HTMLElement | null;
window.setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection?.toString().trim() || "";
if (!selection || !selectedText) {
threadSelection.visible = false;
return;
}
if (
!container ||
!container.contains(selection.anchorNode) ||
!container.contains(selection.focusNode)
) {
threadSelection.visible = false;
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
threadSelection.message = message;
threadSelection.selectedText = selectedText;
threadSelection.left = Math.min(
window.innerWidth - 180,
Math.max(12, rect.left + rect.width / 2 - 70),
);
threadSelection.top = Math.max(12, rect.top - 42);
threadSelection.visible = true;
}, 0);
}
async function createThreadFromSelection() {
const message = threadSelection.message;
if (!currSessionId.value || !message?.id || !threadSelection.selectedText) return;
try {
const response = await axios.post("/api/chat/thread/create", {
session_id: currSessionId.value,
parent_message_id: message.id,
selected_text: threadSelection.selectedText,
});
if (response.data?.status !== "ok") {
toast.error(response.data?.message || tm("thread.createFailed"));
return;
}
const thread = response.data?.data as ChatThread | undefined;
if (!thread) {
toast.error(tm("thread.createFailed"));
return;
}
message.threads = message.threads || [];
if (!message.threads.some((item) => item.thread_id === thread.thread_id)) {
message.threads.push(thread);
}
openThreadPanel(thread);
window.getSelection()?.removeAllRanges();
} catch (error) {
toast.error(
axios.isAxiosError(error)
? error.response?.data?.message || error.message
: tm("thread.createFailed"),
);
console.error("Failed to create thread:", error);
} finally {
threadSelection.visible = false;
}
}
function openThreadPanel(thread: ChatThread) {
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
activeThread.value = thread;
threadPanelOpen.value = true;
}
function openRefsSidebar(refs: unknown) {
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
selectedRefs.value =
refs && typeof refs === "object" ? (refs as Record<string, unknown>) : null;
refsSidebarOpen.value = true;
}
function openReasoningPanel(payload: {
message: ChatRecord;
blockIndex: number;
}) {
threadPanelOpen.value = false;
activeThread.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
activeReasoningTarget.value = payload;
reasoningPanelOpen.value = true;
}
async function deleteThread(thread: ChatThread) {
if (deletingThread.value) return;
if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return;
deletingThread.value = true;
try {
await axios.post("/api/chat/thread/delete", {
thread_id: thread.thread_id,
});
removeThreadFromMessages(thread.thread_id);
if (activeThread.value?.thread_id === thread.thread_id) {
threadPanelOpen.value = false;
activeThread.value = null;
}
} catch (error) {
console.error("Failed to delete thread:", error);
} finally {
deletingThread.value = false;
}
}
function removeThreadFromMessages(threadId: string) {
for (const message of activeMessages.value) {
if (!message.threads?.length) continue;
message.threads = message.threads.filter(
(thread) => thread.thread_id !== threadId,
);
}
}
async function handleFilesSelected(files: FileList) {
const selectedFiles = Array.from(files || []);
for (const file of selectedFiles) {
if (file.type.startsWith("image/")) {
await processAndUploadImage(file);
} else {
await processAndUploadFile(file);
}
}
}
function toggleStreaming() {
enableStreaming.value = !enableStreaming.value;
}
function startRecording() {
isRecording.value = true;
}
function stopRecording() {
isRecording.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;
}
function scrollToBottom() {
nextTick(() => {
const container = messagesContainer.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
shouldStickToBottom.value = true;
});
}
async function stopCurrentSession() {
if (!currSessionId.value) return;
try {
await stopSession(currSessionId.value);
} catch (error) {
console.error("Failed to stop session:", error);
}
}
</script>
<style scoped>
.chat-ui {
--chat-sidebar-bg: #fbfbfb;
--chat-session-active-bg: #efefef;
--chat-page-bg: rgb(var(--v-theme-background));
--chat-border: rgba(var(--v-border-color), 0.16);
--chat-muted: rgba(var(--v-theme-on-surface), 0.62);
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--chat-page-bg);
color: rgb(var(--v-theme-on-surface));
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
}
.chat-ui.is-dark {
--chat-sidebar-bg: #2d2d2d;
--chat-session-active-bg: rgba(255, 255, 255, 0.08);
--chat-border: rgba(255, 255, 255, 0.1);
}
.chat-sidebar {
height: 100%;
background: var(--chat-sidebar-bg);
}
.chat-sidebar.collapsed {
background: transparent;
}
.chat-sidebar :deep(.v-navigation-drawer__content) {
display: flex;
flex-direction: column;
height: 100%;
}
.sidebar-top {
padding: 12px;
}
.brand-row {
display: flex;
align-items: center;
}
.brand-row {
justify-content: flex-start;
min-height: 36px;
margin-bottom: 8px;
}
.sidebar-toggle,
.new-chat-btn,
.settings-btn {
color: var(--chat-muted);
border-radius: 8px;
}
.sidebar-action-icon {
color: currentcolor;
}
.sidebar-toggle {
width: 40px;
height: 40px;
min-width: 40px;
}
.new-chat-btn,
.settings-btn {
width: 100%;
justify-content: flex-start;
border-radius: 8px;
text-transform: none;
font-weight: 500;
}
.sidebar-provider-btn {
margin-bottom: 8px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
padding-inline: 12px;
}
.new-chat-btn.icon-only,
.settings-btn.icon-only {
width: 40px;
height: 40px;
min-width: 40px;
justify-content: center;
}
.chat-sidebar.collapsed .brand-row,
.chat-sidebar.collapsed .sidebar-footer {
display: flex;
justify-content: center;
}
.sidebar-toggle:hover,
.new-chat-btn:hover,
.settings-btn:hover {
background: var(--chat-session-active-bg);
}
.sidebar-workspace-btn--active {
background: var(--chat-session-active-bg);
color: rgb(var(--v-theme-on-surface));
}
.chevron-collapsed {
transform: rotate(180deg);
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 4px 12px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.session-item {
width: 100%;
min-height: 38px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
text-align: left;
}
.session-item:hover,
.session-item.active {
background: var(--chat-session-active-bg);
}
.session-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.session-progress {
flex-shrink: 0;
}
.session-actions {
display: none;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.session-item:hover .session-actions,
.session-item:focus-within .session-actions {
display: flex;
}
.session-action-btn {
color: var(--chat-muted);
}
.session-action-btn:hover {
color: rgb(var(--v-theme-on-surface));
}
.empty-sessions {
padding: 12px;
color: var(--chat-muted);
font-size: 13px;
}
.sidebar-footer {
margin-top: auto;
padding: 10px 12px 14px;
}
.chat-main {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.chat-main.empty-chat {
justify-content: center;
}
.provider-workspace-shell {
flex: 1;
min-height: 0;
overflow: hidden;
}
.provider-workspace-page {
height: 100%;
min-height: 0;
}
.messages-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 24px max(24px, calc((100% - 980px) / 2)) 18px;
}
.empty-chat .messages-panel {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
padding: 0 max(24px, calc((100% - 980px) / 2)) 20px;
}
.center-state,
.welcome-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.empty-chat .welcome-state {
height: auto;
}
.welcome-title {
font-size: 28px;
font-weight: 800;
}
.welcome-subtitle {
margin-top: 8px;
color: var(--chat-muted);
font-size: 16px;
}
.session-project-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
max-width: min(760px, 82%);
margin-bottom: 18px;
color: var(--chat-muted);
font-size: 13px;
font-weight: 500;
}
.session-project-breadcrumb span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-selection-action {
position: fixed;
z-index: 1200;
pointer-events: auto;
}
.thread-selection-button {
min-height: 34px;
padding: 0 14px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
border-radius: 999px;
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.14);
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.composer-shell {
position: relative;
z-index: 1;
background: var(--chat-page-bg);
padding: 0 0 18px;
}
.composer-shell::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -36px;
height: 36px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0),
var(--chat-page-bg)
);
}
.composer-shell :deep(.input-area) {
border-top: 0;
}
.empty-chat .composer-shell {
padding-bottom: 0;
}
.empty-chat .composer-shell::before {
display: none;
}
kbd {
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.08);
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;
}
:deep(.list-node) {
margin-top: .5rem;
margin-bottom: .5rem;
}
@media (max-width: 760px) {
.messages-panel {
padding: 18px 14px;
}
.composer-shell,
.project-composer-shell {
padding: 0;
}
}
</style>