Compare commits

...

1 Commits

Author SHA1 Message Date
Soulter
81f9bbbb3b refactor: chatui style 2026-04-12 20:46:41 +08:00
22 changed files with 5033 additions and 4909 deletions

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 243 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -36,10 +36,6 @@
content: "\F0899";
}
.mdi-account-voice::before {
content: "\F05CB";
}
.mdi-alert::before {
content: "\F0026";
}
@@ -180,10 +176,6 @@
content: "\F0132";
}
.mdi-checkbox-multiple-marked-outline::before {
content: "\F0139";
}
.mdi-chevron-double-left::before {
content: "\F013D";
}
@@ -244,14 +236,6 @@
content: "\F0626";
}
.mdi-code-tags::before {
content: "\F0174";
}
.mdi-code-tags-check::before {
content: "\F0694";
}
.mdi-cog::before {
content: "\F0493";
}
@@ -360,14 +344,6 @@
content: "\F0C68";
}
.mdi-emoticon-confused::before {
content: "\F10DE";
}
.mdi-emoticon-confused-outline::before {
content: "\F10DF";
}
.mdi-export::before {
content: "\F0207";
}
@@ -440,10 +416,6 @@
content: "\F0A4D";
}
.mdi-file-upload-outline::before {
content: "\F0A4E";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -456,14 +428,6 @@
content: "\F0236";
}
.mdi-flash::before {
content: "\F0241";
}
.mdi-flash-off::before {
content: "\F0243";
}
.mdi-folder::before {
content: "\F024B";
}
@@ -552,6 +516,10 @@
content: "\F0EFE";
}
.mdi-image-outline::before {
content: "\F0976";
}
.mdi-import::before {
content: "\F02FA";
}
@@ -576,18 +544,10 @@
content: "\F0309";
}
.mdi-keyboard-outline::before {
content: "\F097B";
}
.mdi-label::before {
content: "\F0315";
}
.mdi-lan-connect::before {
content: "\F0318";
}
.mdi-language-markdown::before {
content: "\F0354";
}
@@ -648,12 +608,12 @@
content: "\F035D";
}
.mdi-menu-right::before {
content: "\F035F";
.mdi-menu-open::before {
content: "\F0BAB";
}
.mdi-message-off-outline::before {
content: "\F164E";
.mdi-menu-right::before {
content: "\F035F";
}
.mdi-message-outline::before {
@@ -668,10 +628,6 @@
content: "\F036A";
}
.mdi-microphone::before {
content: "\F036C";
}
.mdi-microphone-message::before {
content: "\F050A";
}
@@ -756,10 +712,6 @@
content: "\F1353";
}
.mdi-phone-in-talk::before {
content: "\F03F6";
}
.mdi-pin::before {
content: "\F0403";
}
@@ -1036,10 +988,6 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,696 +0,0 @@
<template>
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 8px; opacity: 0.6;">
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
:projects="projects"
@selectProject="$emit('selectProject', $event)"
@createProject="$emit('createProject')"
@editProject="$emit('editProject', $event)"
@deleteProject="$emit('deleteProject', $event)"
/>
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in transportOptions"
:key="opt.value"
:value="opt.value"
@click="handleTransportModeChange(opt.value)"
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props {
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
selectedProjectId?: string | null;
transportMode: 'sse' | 'websocket';
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
projects: () => []
});
const emit = defineEmits<{
newChat: [];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// 从 localStorage 读取侧边栏折叠状态
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
if (savedCollapsedState !== null) {
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
} else {
sidebarCollapsed.value = true;
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
async function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteConversation', session.session_id);
}
}
function handleTransportModeChange(mode: string | null) {
if (mode === 'sse' || mode === 'websocket') {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.sidebar-collapsed {
max-width: 60px;
min-width: 60px;
transition: all 0.3s ease;
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
.sidebar-collapse-btn-container {
margin: 8px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.new-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
@media (max-width: 768px) {
.conversation-actions {
opacity: 1 !important;
visibility: visible !important;
}
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.sidebar-spacer {
flex-grow: 1;
}
.sidebar-footer {
padding: 8px 8px;
padding-bottom: 16px;
flex-shrink: 0;
}
.settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
}
.chat-settings-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
</style>

View File

@@ -1,162 +1,228 @@
<template>
<div>
<!-- 项目按钮 -->
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
{{ tm('project.title') }}
<template v-slot:append>
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px;">
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
<v-list-item v-for="project in projects" :key="project.project_id"
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
<template v-slot:prepend>
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
</template>
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
<template v-slot:append>
<div class="project-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
@click.stop="$emit('editProject', project)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
color="error" @click.stop="handleDeleteProject(project)" />
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-expand-transition>
<div class="project-list-shell">
<!-- 项目按钮 -->
<div class="project-button-wrap">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded">
<v-icon size="20" class="project-action-icon mr-2">
mdi-folder-outline
</v-icon>
<span class="project-btn-title">{{ tm("project.title") }}</span>
<v-spacer />
<v-icon size="18" class="project-toggle-icon">
{{ expanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
</v-icon>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" class="project-list-wrap">
<button
class="project-row create-project-item"
type="button"
@click="$emit('createProject')"
>
<span class="project-emoji">
<v-icon size="18">mdi-plus</v-icon>
</span>
<span class="project-title">{{ tm("project.create") }}</span>
</button>
<button
v-for="project in projects"
:key="project.project_id"
class="project-row project-item"
:class="{ active: selectedProjectId === project.project_id }"
type="button"
@click="$emit('selectProject', project.project_id)"
>
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
<span class="project-title">{{ project.title }}</span>
<span class="project-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-project-btn"
@click.stop="$emit('editProject', project)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-project-btn"
color="error"
@click.stop="handleDeleteProject(project)"
/>
</span>
</button>
</div>
</v-expand-transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
}
interface Props {
projects: Project[];
initialExpanded?: boolean;
projects: Project[];
initialExpanded?: boolean;
selectedProjectId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
initialExpanded: false
initialExpanded: false,
selectedProjectId: null,
});
const emit = defineEmits<{
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const expanded = ref(props.initialExpanded);
// 从 localStorage 读取项目展开状态
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
const savedProjectsExpandedState = localStorage.getItem("projectsExpanded");
if (savedProjectsExpandedState !== null) {
expanded.value = JSON.parse(savedProjectsExpandedState);
expanded.value = JSON.parse(savedProjectsExpandedState);
}
function toggleExpanded() {
expanded.value = !expanded.value;
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
expanded.value = !expanded.value;
localStorage.setItem("projectsExpanded", JSON.stringify(expanded.value));
}
async function handleDeleteProject(project: Project) {
const message = tm('project.confirmDelete', { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteProject', project.project_id);
}
const message = tm("project.confirmDelete", { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteProject", project.project_id);
}
}
</script>
<style scoped>
.project-list-shell {
margin-top: 6px;
}
.project-button-wrap {
opacity: 0.6;
}
.project-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
text-transform: none;
justify-content: flex-start;
background-color: transparent !important;
border-radius: 8px;
padding: 8px 12px !important;
text-transform: none;
font-weight: 500;
}
.project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
margin-bottom: 2px;
.project-action-icon {
color: currentcolor;
}
.project-item:hover {
background-color: rgba(103, 58, 183, 0.05);
.project-btn-title {
min-width: 0;
}
.project-toggle-icon {
margin-left: 10px;
}
.project-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.project-row {
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;
}
.project-row:hover,
.project-row.active {
background: var(--chat-session-active-bg);
}
.project-item:hover .project-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.project-emoji {
font-size: 16px;
margin-right: 6px;
width: 20px;
flex: 0 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.project-title {
font-size: 13px;
font-weight: 500;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.project-actions {
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-project-btn,
.delete-project-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-project-btn:hover,
.delete-project-btn:hover {
opacity: 1;
opacity: 1;
}
.create-project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
opacity: 0.7;
opacity: 0.7;
}
.create-project-item:hover {
background-color: rgba(103, 58, 183, 0.08);
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,189 +1,214 @@
<template>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item v-for="session in sessions" :key="session.session_id"
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
<v-list-item-title>
{{ session.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-session-btn"
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-session-btn" color="error"
@click.stop="handleDeleteSession(session)" />
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
<p>{{ tm('project.noSessions') }}</p>
</div>
</v-card>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || "📁" }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item
v-for="session in sessions"
:key="session.session_id"
@click="$emit('selectSession', session.session_id)"
class="project-session-item"
rounded="lg"
>
<v-list-item-title>
{{ session.display_name || tm("conversation.newConversation") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-session-btn"
@click.stop="
$emit(
'editSessionTitle',
session.session_id,
session.display_name ?? '',
)
"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-session-btn"
color="error"
@click.stop="handleDeleteSession(session)"
/>
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon
icon="mdi-message-outline"
size="large"
color="grey-lighten-1"
></v-icon>
<p>{{ tm("project.noSessions") }}</p>
</div>
</v-card>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { Project } from '@/components/chat/ProjectList.vue';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { useModuleI18n } from "@/i18n/composables";
import type { Project } from "@/components/chat/ProjectList.vue";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
interface Session {
session_id: string;
display_name?: string;
updated_at: string;
session_id: string;
display_name?: string | null;
updated_at: string;
}
interface Props {
project?: Project | null;
sessions: Session[];
project?: Project | null;
sessions: Session[];
}
defineProps<Props>();
const emit = defineEmits<{
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
return new Date(dateString).toLocaleString();
}
async function handleDeleteSession(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteSession', session.session_id);
}
const sessionTitle =
session.display_name || tm("conversation.newConversation");
const message = tm("conversation.confirmDelete", { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session.session_id);
}
}
</script>
<style scoped>
.project-sessions-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
}
.project-header {
text-align: center;
margin-bottom: 32px;
max-width: 600px;
text-align: center;
margin-bottom: 32px;
max-width: 600px;
}
.project-header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.project-header-emoji {
font-size: 48px;
font-size: 48px;
}
.project-header-title {
font-size: 32px;
font-weight: 600;
font-size: 32px;
font-weight: 600;
}
.project-header-description {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
}
.project-input-slot {
width: 100%;
max-width: 800px;
margin-bottom: 24px;
width: 100%;
max-width: 800px;
margin-bottom: 24px;
}
.project-sessions-list {
width: 100%;
max-width: 680px;
background-color: transparent !important;
width: 100%;
max-width: 680px;
background-color: transparent !important;
}
.project-session-item {
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
}
.project-session-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background-color: rgba(103, 58, 183, 0.05);
}
.project-session-item:hover .session-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.session-actions {
display: flex;
gap: 2px;
opacity: 1;
display: flex;
gap: 2px;
opacity: 1;
}
.no-sessions-in-project {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
}
.no-sessions-in-project p {
margin-top: 12px;
font-size: 14px;
margin-top: 12px;
font-size: 14px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,356 +1,616 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<div class="standalone-chat">
<section ref="messagesContainer" class="standalone-messages">
<div v-if="initializing" class="standalone-state">
<v-progress-circular indeterminate size="28" width="3" />
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
<div v-else-if="!activeMessages.length" class="standalone-state">
<div class="welcome-title">{{ tm("welcome.title") }}</div>
</div>
<div v-else class="message-list">
<div
v-for="(msg, msgIndex) in activeMessages"
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
class="message-row"
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
>
<div class="message-stack">
<div
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>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<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">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</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-braces</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>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</div>
</v-card-text>
</v-card>
</div>
</div>
</div>
</section>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
<section class="standalone-composer">
<ChatInput
ref="inputRef"
v-model:prompt="draft"
:staged-images-url="stagedImagesUrl"
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending || initializing"
:enable-streaming="enableStreaming"
:is-recording="false"
:is-running="Boolean(currSessionId && isSessionRunning(currSessionId))"
:session-id="currSessionId || null"
:current-session="currentSession"
:config-id="configId || 'default'"
send-shortcut="enter"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="enableStreaming = !enableStreaming"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@paste-image="handlePaste"
@file-select="handleFilesSelected"
/>
</section>
<v-overlay
v-model="imagePreview.visible"
class="image-preview-overlay"
scrim="rgba(0, 0, 0, 0.86)"
@click="closeImage"
>
<img class="preview-image" :src="imagePreview.url" alt="preview" />
</v-overlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from "vue";
import axios from "axios";
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import ChatInput from "@/components/chat/ChatInput.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
useMessages,
type ChatRecord,
type MessagePart,
type TransportMode,
} from "@/composables/useMessages";
import type { Session } from "@/composables/useSessions";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
const props = withDefaults(defineProps<{ configId?: string | null }>(), {
configId: "default",
});
const { t } = useI18n();
const { error: showError } = useToast();
setCustomComponents("chat-message", {
ref: RefNode,
code_block: MarkdownCodeBlockNode,
});
const { tm } = useModuleI18n("features/chat");
const customizer = useCustomizerStore();
const currSessionId = ref("");
const currentSession = ref<Session | null>(null);
const draft = ref("");
const initializing = ref(false);
const enableStreaming = ref(true);
const shouldStickToBottom = ref(true);
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const imagePreview = reactive({ visible: false, url: "" });
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function bindConfigToSession(sessionId: string) {
const confId = (props.configId || '').trim();
if (!confId || confId === 'default') {
return;
}
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {
umo: umoDetails.umo,
conf_id: confId
});
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
try {
await bindConfigToSession(sessionId);
} catch (err) {
console.error('Failed to bind config to session', err);
}
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const customMarkdownTags = ["ref"];
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
stagedFiles,
stagedImagesUrl,
stagedAudioUrl,
stagedNonImageFiles,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
sending,
activeMessages,
isSessionRunning,
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
} = useMessages({
currentSessionId: currSessionId,
onStreamUpdate: () => {
if (shouldStickToBottom.value) {
scrollToBottom();
}
}
},
});
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
async function handleStopMessage() {
await stopMsg();
}
const transportMode = computed<TransportMode>(() =>
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
: "sse",
);
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
await ensureSession();
inputRef.value?.focusInput();
});
onBeforeUnmount(() => {
cleanupMediaCache();
cleanupMediaCache();
});
async function ensureSession() {
if (currSessionId.value) return currSessionId.value;
initializing.value = true;
try {
const response = await axios.get("/api/chat/new_session");
const session = response.data?.data as Session;
currSessionId.value = session.session_id;
currentSession.value = session;
await bindConfigToSession(session.session_id);
return session.session_id;
} finally {
initializing.value = false;
}
}
async function bindConfigToSession(sessionId: string) {
const confId = props.configId || "default";
const umo = buildWebchatUmoDetails(sessionId, false).umo;
await axios.post("/api/config/umo_abconf_route/update", {
umo,
conf_id: confId,
});
}
async function sendCurrentMessage() {
if (!draft.value.trim() && !stagedFiles.value.length) return;
const sessionId = await ensureSession();
const text = draft.value.trim();
const parts = buildOutgoingParts(text);
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
const selection = inputRef.value?.getCurrentSelection();
const { botRecord } = createLocalExchange({ sessionId, messageId, parts });
draft.value = "";
clearStaged();
scrollToBottom();
sendMessageStream({
sessionId,
messageId,
parts,
transport: transportMode.value,
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
botRecord,
});
}
function buildOutgoingParts(text: string): MessagePart[] {
const parts: MessagePart[] = [];
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 hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
async function stopCurrentSession() {
if (!currSessionId.value) return;
await stopSession(currSessionId.value);
}
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 scrollToBottom() {
nextTick(() => {
const container = messagesContainer.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
shouldStickToBottom.value = true;
});
}
function messageRefs(message: ChatRecord) {
const refs = messageContent(message).refs;
if (refs && typeof refs === "object" && Array.isArray(refs.used)) {
return refs as { used?: Array<Record<string, unknown>> };
}
return null;
}
function partUrl(part: MessagePart) {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id)
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
part.attachment_id,
)}`;
if (part.filename)
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
return "";
}
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args || normalized.arguments);
normalized.result = parseJsonSafe(normalized.result);
if (!normalized.ts) normalized.ts = Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
function formatJson(value: unknown) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function openImage(url: string) {
imagePreview.url = url;
imagePreview.visible = true;
}
function closeImage() {
imagePreview.visible = false;
imagePreview.url = "";
}
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.standalone-chat {
--standalone-muted: rgba(var(--v-theme-on-surface), 0.62);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: rgb(var(--v-theme-background));
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
.standalone-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 22px 14px;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.standalone-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
font-size: 24px;
font-weight: 700;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
.message-row {
display: flex;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
.message-row.from-user {
justify-content: flex-end;
}
.message-stack {
max-width: 88%;
}
.from-user .message-stack {
max-width: 70%;
}
.message-bubble {
border-radius: 8px;
padding: 10px 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.message-bubble.user {
padding: 12px 18px;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
}
.message-bubble.bot {
padding-left: 0;
background: transparent;
}
.plain-content {
white-space: pre-wrap;
}
.loading-message,
.tool-call-inline-status {
color: var(--standalone-muted);
}
.image-part {
display: block;
border: 0;
padding: 0;
margin-top: 8px;
background: transparent;
cursor: zoom-in;
}
.image-part img {
max-width: min(360px, 100%);
max-height: 320px;
border-radius: 8px;
object-fit: contain;
}
.audio-part,
.video-part {
display: block;
max-width: 100%;
margin-top: 8px;
}
.video-part {
max-height: 320px;
border-radius: 8px;
}
.file-part {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.tool-call-block {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.message-bubble.bot
> .tool-call-block:first-child
:deep(.tool-call-card:first-child) {
margin-top: 0;
}
.unknown-part {
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
padding: 10px;
background: rgba(var(--v-theme-on-surface), 0.06);
font-size: 13px;
line-height: 1.5;
}
.standalone-composer {
position: relative;
z-index: 1;
padding-bottom: 10px;
background: rgb(var(--v-theme-background));
}
.standalone-composer::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -32px;
height: 32px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0),
rgb(var(--v-theme-background))
);
}
.standalone-composer :deep(.input-area) {
border-top: 0;
}
.image-preview-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: min(92vw, 1000px);
max-height: 88vh;
border-radius: 8px;
object-fit: contain;
}
</style>

View File

@@ -1,144 +0,0 @@
<template>
<div class="welcome-container fade-in">
<div v-if="isLoading" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<template v-else>
<div class="welcome-content">
<div class="welcome-title">
<span class="bot-name-container">
<span class="bot-name-text">
Hello, I'm <span class="highlight-name">AstrBot</span>
</span>
<span class="bot-name-star"></span>
</span>
</div>
</div>
<div class="welcome-input">
<slot></slot>
</div>
</template>
</div>
</template>
<script setup lang="ts">
interface Props {
isLoading?: boolean;
}
withDefaults(defineProps<Props>(), {
isLoading: false
});
</script>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-container {
height: 100%;
width: 100%;
justify-content: center;
display: flex;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-content {
padding: 24px 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.welcome-title {
font-size: 28px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-input {
width: 75%;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name-container {
display: flex;
align-items: center;
}
.highlight-name {
color: var(--v-theme-secondary);
font-weight: 700;
}
.bot-name-text {
overflow: hidden;
white-space: nowrap;
width: 0;
opacity: 0;
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 0.2s;
}
.bot-name-star {
margin-left: 0;
display: inline-block;
transform-origin: center;
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
animation-delay: 0.2s;
padding-left: 4px;
}
@keyframes revealText {
from {
width: 0;
opacity: 0;
}
to {
width: 9.2em;
opacity: 1;
}
}
@keyframes rotateStar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@media (max-width: 600px) {
.welcome-input {
width: 100%;
}
}
</style>

View File

@@ -1,109 +1,121 @@
<template>
<div v-if="refs && refs.used && refs.used.length > 0" class="refs-container" @click="handleClick">
<div class="refs-avatars">
<div v-for="(ref, refIdx) in refs.used.slice(0, 3)" :key="refIdx" class="ref-avatar"
:style="{ zIndex: 3 - refIdx }">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-favicon"
@error="(e) => e.target.style.display = 'none'" />
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray;">
{{ tm('refs.sources') }}
</span>
</div>
<div
v-if="refs && refs.used && refs.used.length > 0"
class="refs-container"
@click="handleClick"
>
<div class="refs-avatars">
<div
v-for="(ref, refIdx) in refs.used.slice(0, 3)"
:key="refIdx"
class="ref-avatar"
:style="{ zIndex: 3 - refIdx }"
>
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray">
{{ tm("refs.sources") }}
</span>
</div>
</div>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'ActionRef',
props: {
refs: {
type: Object,
default: null
}
name: "ActionRef",
props: {
refs: {
type: Object,
default: null,
},
emits: ['open-refs'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
},
emits: ["open-refs"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
// Handle click to open refs sidebar
handleClick() {
this.$emit('open-refs', this.refs);
}
}
}
// Handle click to open refs sidebar
handleClick() {
this.$emit("open-refs", this.refs);
},
},
};
</script>
<style scoped>
.refs-container {
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
}
.refs-container:hover {
background-color: rgba(103, 58, 183, 0.08);
background-color: rgba(103, 58, 183, 0.08);
}
.refs-avatars {
display: flex;
align-items: center;
position: relative;
display: flex;
align-items: center;
position: relative;
}
.ref-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.ref-avatar:not(:first-child) {
margin-left: -8px;
margin-left: -8px;
}
.ref-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-initial {
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
}
.refs-more {
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="markdown-content">
<MarkdownRender
custom-id="chat-message"
:content="content"
:custom-html-tags="customHtmlTags"
:is-dark="isDark"
:typewriter="false"
:max-live-nodes="0"
/>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
const props = defineProps<{
content: string;
refs: { used?: Array<Record<string, unknown>> } | null;
isDark: boolean;
customHtmlTags: string[];
}>();
const isDarkRef = computed(() => props.isDark);
const refsByIndex = computed(() => {
const messageRefs = props.refs;
const refs =
messageRefs && Array.isArray(messageRefs.used) ? messageRefs.used : [];
return refs.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
if (item.index != null) {
acc[String(item.index)] = item;
}
return acc;
}, {});
});
provide("isDark", isDarkRef);
provide("webSearchResults", () => refsByIndex.value);
</script>

View File

@@ -1,433 +0,0 @@
<template>
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
<template #label="{ expanded }">
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
mdi-web
</v-icon>
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
mdi-console-line
</v-icon>
<v-icon size="x-small" v-else>
mdi-wrench
</v-icon>
{{ tm('actions.toolCallUsed', { name: toolCall.name }) }}
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">{{ toolCall.id }}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
</ToolCallItem>
</transition-group>
</div>
<!-- iPython Tool Block -->
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
<template #label="{ expanded }">
<v-icon size="x-small">
mdi-code-json
</v-icon>
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
formatDuration(renderPart.toolCall.finished_ts -
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
:force-expanded="true" />
</template>
</ToolCallItem>
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
custom-id="message-list" :custom-html-tags="['ref']"
:content="normalizeMarkdownContent(renderPart.part.text)" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"/>
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
@click="emitOpenImage(renderPart.part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="renderPart.part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
</a>
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template>
<script setup>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import IPythonToolBlock from './IPythonToolBlock.vue';
import ToolCallItem from './ToolCallItem.vue';
const props = defineProps({
parts: {
type: Array,
required: true
},
isDark: {
type: Boolean,
default: false
},
currentTime: {
type: Number,
default: 0
},
downloadingFiles: {
type: Object,
default: () => new Set()
}
});
const emit = defineEmits(['open-image-preview', 'download-file']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const emitOpenImage = (url) => {
emit('open-image-preview', url);
};
const emitDownloadFile = (file) => {
emit('download-file', file);
};
const isMarkdownCodeFence = (text) => /^(```|~~~)/.test(text.trim());
const looksLikeStandaloneHtml = (text) => {
const normalized = text.trim();
if (!normalized) return false;
if (!/(<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(normalized)) return false;
return /(<\/html>|<\/body>|<\/head>|<form\b|<input\b|<button\b)/i.test(normalized);
};
const normalizeMarkdownContent = (text) => {
if (typeof text !== 'string') return text;
if (isMarkdownCodeFence(text) || !looksLikeStandaloneHtml(text)) return text;
return `\`\`\`\`html\n${text}\n\`\`\`\``;
};
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
};
const getElapsedTime = (startTs) => {
const elapsed = props.currentTime - startTs;
return formatDuration(elapsed);
};
const formatToolResult = (result) => {
if (!result) return '';
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
}
return JSON.stringify(result, null, 2);
};
const formatToolArgs = (args) => {
if (!args) return '';
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} catch {
return args;
}
}
return JSON.stringify(args, null, 2);
};
const isIPythonTool = (toolCall) => {
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
};
const getRenderParts = (messageParts) => {
if (!Array.isArray(messageParts)) return [];
const rendered = [];
let pendingToolCalls = [];
let groupIndex = 0;
const flushPending = (endIndex) => {
if (!pendingToolCalls.length) return;
rendered.push({
type: 'tool_group',
toolCalls: pendingToolCalls,
key: `tool-group-${groupIndex}-${endIndex}`
});
pendingToolCalls = [];
groupIndex += 1;
};
messageParts.forEach((part, idx) => {
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
part.tool_calls.forEach((toolCall, tcIndex) => {
if (isIPythonTool(toolCall)) {
flushPending(idx - 1);
rendered.push({
type: 'ipython',
toolCall,
key: `ipython-${idx}-${tcIndex}`
});
return;
}
pendingToolCalls.push(toolCall);
});
return;
}
flushPending(idx - 1);
rendered.push({
type: 'part',
part,
key: `part-${idx}`
});
});
flushPending(messageParts.length - 1);
return rendered;
};
</script>
<style scoped>
.tool-call-compact {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.tool-call-group-title {
font-size: 13px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
}
.tool-call-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 6px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 320px;
background-color: transparent;
}
.tool-call-item-enter-active,
.tool-call-item-leave-active {
transition: all 0.2s ease;
}
.tool-call-item-enter-from,
.tool-call-item-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.ipython-icon,
.tool-call-chevron {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.tool-call-chevron.rotated {
transform: rotate(90deg);
}
.embedded-images {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedded-image {
display: flex;
justify-content: flex-start;
}
.bot-embedded-image {
max-width: 55%;
width: auto;
height: auto;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
}
.embedded-audio {
width: 300px;
margin-top: 8px;
}
.embedded-audio .audio-player {
width: 100%;
max-width: 300px;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: rgba(var(--v-theme-primary), 0.08);
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 8px;
text-decoration: none;
font-size: 13px;
transition: all 0.2s ease;
max-width: 320px;
}
.file-link-download {
cursor: pointer;
}
</style>

View File

@@ -1,122 +1,288 @@
<template>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<div class="reasoning-header" @click="toggleExpanded">
<v-icon size="small" class="reasoning-icon" :class="{ 'rotate-90': isExpanded }">
mdi-chevron-right
</v-icon>
<span class="reasoning-title">
{{ tm('reasoning.thinking') }}
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<button class="reasoning-header" type="button" @click="toggleExpanded">
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
</span>
<v-icon
size="22"
class="reasoning-icon"
:class="{ 'rotate-90': isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
:content="reasoning"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
</div>
<transition :name="previewTransitionName" mode="out-in">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
{{ previewText }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { MarkdownRender } from "markstream-vue";
const props = defineProps({
reasoning: {
type: String,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
reasoning: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
isStreaming: {
type: Boolean,
default: false,
},
hasNonReasoningContent: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const previewText = ref("");
const previewKey = ref(0);
let previewTimer = null;
let previewStartTimer = null;
const showStreamingPreview = computed(
() =>
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent &&
previewText.value,
);
const previewTransitionName = computed(() =>
props.hasNonReasoningContent
? "reasoning-preview-collapse"
: "reasoning-preview-fade",
);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const latestReasoningPreview = () => {
const lines = props.reasoning
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.slice(-3).join("\n");
};
const updatePreviewLine = () => {
const nextText = latestReasoningPreview();
if (!nextText || nextText === previewText.value) return;
previewText.value = nextText;
previewKey.value += 1;
};
const stopPreviewTimer = () => {
if (!previewTimer) return;
clearInterval(previewTimer);
previewTimer = null;
};
const stopPreviewStartTimer = () => {
if (!previewStartTimer) return;
clearTimeout(previewStartTimer);
previewStartTimer = null;
};
const startPreviewTimer = () => {
updatePreviewLine();
if (!previewTimer) {
previewTimer = setInterval(updatePreviewLine, 2000);
}
};
const syncPreviewTimer = () => {
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
if (!previewTimer && !previewStartTimer) {
previewStartTimer = setTimeout(() => {
previewStartTimer = null;
if (
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent
) {
startPreviewTimer();
}
}, 2000);
}
return;
}
stopPreviewStartTimer();
stopPreviewTimer();
if (!props.isStreaming) {
previewText.value = "";
}
};
watch(
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
syncPreviewTimer,
{
immediate: true,
},
);
onBeforeUnmount(() => {
stopPreviewStartTimer();
stopPreviewTimer();
});
</script>
<style scoped>
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 20px;
overflow: hidden;
width: fit-content;
.reasoning-block {
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
.reasoning-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
margin-top: 8px;
padding: 0;
color: rgba(var(--v-theme-on-surface), 0.7);
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-preview {
max-width: 100%;
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.52);
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
white-space: pre-line;
font: inherit;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
font-size: inherit;
line-height: inherit;
color: inherit;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
.rotate-90 {
transform: rotate(90deg);
transform: rotate(90deg);
}
.reasoning-preview-fade-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-leave-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-enter-from,
.reasoning-preview-fade-leave-to {
opacity: 0;
}
.reasoning-preview-collapse-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-collapse-leave-active {
overflow: hidden;
transition:
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
opacity 0.35s ease-in,
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
}
.reasoning-preview-collapse-enter-from {
opacity: 0;
}
.reasoning-preview-collapse-leave-from {
max-height: 5rem;
opacity: 1;
transform: translateY(0);
}
.reasoning-preview-collapse-leave-to {
max-height: 0;
margin-top: 0;
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,76 +1,91 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="chipStyle" :href="url"
target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span>
</v-chip>
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
<v-chip
v-if="resultData"
class="ref-chip"
size="x-small"
variant="flat"
:style="chipStyle"
:href="url"
target="_blank"
clickable
>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain || resultData.title || refIndex }}</span>
</v-chip>
</template>
<script setup>
import { computed, inject } from 'vue'
import { computed, inject, unref, useSlots } from "vue";
const props = defineProps({
node: {
type: Object,
required: true
}
})
node: {
type: Object,
default: null,
},
});
console.log('RefNode node:', props.node);
const slots = useSlots();
const injectedIsDark = inject("isDark", false);
const webSearchResults = inject("webSearchResults", () => ({}));
// 从父组件注入的暗黑模式状态和搜索结果
const isDark = inject('isDark', false)
const webSearchResults = inject('webSearchResults', () => ({}))
const isDark = computed(() => Boolean(unref(injectedIsDark)));
const refIndex = computed(() => {
const nodeContent = props.node?.content?.trim();
if (nodeContent) return nodeContent;
return slotText(slots.default?.()).trim();
});
// 从 node.content 中提取 ref index (格式: uuid.idx)
const refIndex = computed(() => props.node?.content?.trim() || '')
// 根据 refIndex 查找对应的 URL
const resultData = computed(() => {
if (!refIndex.value) return null
const results = typeof webSearchResults === 'function' ? webSearchResults() : webSearchResults
return results?.[refIndex.value] || null
})
if (!refIndex.value) return null;
const results =
typeof webSearchResults === "function"
? webSearchResults()
: webSearchResults;
return results?.[refIndex.value] || null;
});
const url = computed(() => resultData.value?.url || '')
const url = computed(() => resultData.value?.url || "");
const domain = computed(() => {
if (!url.value) return ''
try {
const urlObj = new URL(url.value)
return urlObj.hostname.replace(/^www\./, '')
} catch (e) {
return ''
}
})
if (!url.value) return "";
try {
const urlObj = new URL(url.value);
return urlObj.hostname.replace(/^www\./, "");
} catch (e) {
return "";
}
});
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
backgroundColor: isDark.value
? "rgba(var(--v-theme-on-surface), 0.08)"
: "rgba(var(--v-theme-on-surface), 0.04)",
color: isDark.value
? "rgba(var(--v-theme-on-surface), 0.62)"
: "rgba(var(--v-theme-on-surface), 0.72)",
}));
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
function slotText(nodes = []) {
return nodes
.map((node) => {
if (typeof node.children === "string") return node.children;
if (Array.isArray(node.children)) return slotText(node.children);
return "";
})
.join("");
}
</script>
<style scoped>
.ref-chip {
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
}
.ref-chip:hover {
opacity: 0.8;
}
.ref-fallback {
font-size: 0.9em;
opacity: 0.8;
}
</style>

View File

@@ -1,225 +1,262 @@
<template>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm('refs.title') }}</h3>
<v-btn icon="mdi-close" size="small" variant="text" @click="close"></v-btn>
</div>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm("refs.title") }}</h3>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="close"
></v-btn>
</div>
<div class="refs-list">
<div v-for="(ref, index) in refs?.used || []" :key="index" class="ref-item" @click="openLink(ref.url)">
<div class="ref-item-icon">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-item-favicon"
@error="(e) => e.target.style.display = 'none'" />
<div v-else class="ref-item-initial">{{ getRefInitial(ref.title) }}</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">{{ ref.snippet }}</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
<div class="refs-list">
<div
v-for="(ref, index) in normalizedRefs"
:key="ref.index || index"
class="ref-item"
@click="openLink(ref.url)"
>
<div class="ref-item-icon">
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-item-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<div v-else class="ref-item-initial">
{{ getRefInitial(ref.title) }}
</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">
{{ ref.snippet }}
</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
</transition>
</div>
</div>
</transition>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'RefsSidebar',
props: {
modelValue: {
type: Boolean,
default: false
},
refs: {
type: Object,
default: null
}
name: "RefsSidebar",
props: {
modelValue: {
type: Boolean,
default: false,
},
emits: ['update:modelValue'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
refs: {
type: Object,
default: null,
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
}
},
emits: ["update:modelValue"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
methods: {
close() {
this.isOpen = false;
},
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
normalizedRefs() {
const used = Array.isArray(this.refs?.used)
? this.refs.used
: Array.isArray(this.refs)
? this.refs
: [];
formatUrl(url) {
if (!url) return '';
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
return used
.map((ref) => ({
index: ref?.index,
title: ref?.title || ref?.url || "Reference",
url: ref?.url,
snippet: ref?.snippet,
favicon: ref?.favicon,
}))
.filter((ref) => ref.url);
},
},
methods: {
close() {
this.isOpen = false;
},
openLink(url) {
if (url) {
window.open(url, '_blank');
}
}
}
}
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
formatUrl(url) {
if (!url) return "";
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
openLink(url) {
if (url) {
window.open(url, "_blank");
}
},
},
};
</script>
<style scoped>
.refs-sidebar {
width: 360px;
height: 100%;
background-color: var(--v-theme-surface);
border-left: 1px solid var(--v-theme-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 360px;
height: 100%;
background-color: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), 0.16);
display: flex;
flex-direction: column;
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.refs-list {
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
}
.ref-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
}
.ref-item:hover {
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
}
.ref-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.ref-item-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-item-initial {
font-size: 14px;
font-weight: 600;
color: white;
font-size: 14px;
font-weight: 600;
color: white;
}
.ref-item-content {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.ref-item-title {
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ref-item-url {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-item-snippet {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.ref-item-arrow {
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.ref-item:hover .ref-item-arrow {
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,290 +1,271 @@
<template>
<div class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isExpanded }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<!-- Header -->
<div class="tool-call-header" :class="{ 'is-dark': isDark }" @click="toggleExpanded">
<v-icon size="small" class="tool-call-expand-icon" :class="{ 'expanded': isExpanded }">
mdi-chevron-right
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small" class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small" class="status-icon spinning">mdi-loading</v-icon>
{{ elapsedTime }}
</template>
</span>
</div>
<div class="tool-call-card" :class="{ expanded: isExpanded }">
<button class="tool-call-header" type="button" @click="toggleExpanded">
<v-icon size="16" class="tool-call-icon">{{ toolCallIcon }}</v-icon>
<span class="tool-call-title">
{{ tm("actions.toolCallUsed", { name: displayToolName }) }}
</span>
<span class="tool-call-duration">{{ toolCallDuration }}</span>
<v-icon
size="22"
class="tool-call-expand-icon"
:class="{ expanded: isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<!-- Details -->
<div v-if="isExpanded" class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<!-- ID -->
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value" :style="isDark ? { backgroundColor: 'transparent' } : {}">
{{ toolCall.id }}
<div v-if="isExpanded" class="tool-call-details">
<div v-if="toolCall.id" class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">
{{ toolCall.id }}
</code>
</div>
</div>
<!-- Args -->
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json" :style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{
JSON.stringify(toolCall.args, null, 2)
}}</pre>
</div>
<!-- Result -->
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
formattedResult }}</pre>
</div>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result">{{
formattedResult
}}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps({
toolCall: {
type: Object,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
toolCall: {
type: Object,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const currentTime = ref(Date.now() / 1000);
let timer = null;
const elapsedTime = computed(() => {
if (props.toolCall.finished_ts) return '';
const elapsed = currentTime.value - props.toolCall.ts;
return formatDuration(elapsed);
if (props.toolCall.finished_ts) return "";
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
return formatDuration(currentTime.value - startTime);
});
const displayToolName = computed(() => props.toolCall.name || "tool");
const toolCallIcon = computed(() => {
const name = String(props.toolCall.name || "");
if (name === "astrbot_execute_ipython" || name === "astrbot_execute_python") {
return "mdi-code-braces";
}
if (name.includes("web_search") || name.includes("tavily")) {
return "mdi-web";
}
if (name === "astrbot_execute_shell") {
return "mdi-console-line";
}
return "mdi-wrench";
});
const toolCallDuration = computed(() => {
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
if (props.toolCall.finished_ts) {
return formatDuration(Number(props.toolCall.finished_ts) - startTime);
}
return elapsedTime.value;
});
const formattedResult = computed(() => {
if (!props.toolCall.result) return '';
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
if (!props.toolCall.result) return "";
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
});
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
if (!Number.isFinite(seconds) || seconds < 0) return "";
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const updateTime = () => {
currentTime.value = Date.now() / 1000;
currentTime.value = Date.now() / 1000;
};
onMounted(() => {
// Update time periodically if tool call is running
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
if (timer) {
clearInterval(timer);
}
});
</script>
<style scoped>
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.tool-call-card.expanded {
width: 100%;
width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color;
gap: 8px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-expand-icon.expanded {
transform: rotate(90deg);
transform: rotate(90deg);
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
color: currentcolor;
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
.tool-call-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
.tool-call-duration {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.48);
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
margin-top: 8px;
padding-left: 26px;
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.55);
text-transform: uppercase;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: transparent;
padding: 0;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
max-height: 300px;
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "Output"
},
"toolStatus": {
"done": "Done",
"running": "Running"
},
"conversation": {
"newConversation": "New Conversation",
"noHistory": "No conversation history",
@@ -158,4 +162,4 @@
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
}

View File

@@ -1,151 +1,155 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"toolStatus": {
"done": "Готово",
"running": "Выполняется"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "输出"
},
"toolStatus": {
"done": "已完成",
"running": "运行中"
},
"conversation": {
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
@@ -158,4 +162,4 @@
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
}
}
}