feat(ai): polish chat interface and loading

This commit is contained in:
digua committed 2026-08-19 18:49:20 +08:00
1 parent d53a99359b
commit 349510edf6
7 files changed
+219 -171

No files matched your search

+20 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@/composables/useToast'
@@ -9,6 +9,7 @@ import ChatMessage from './chat/ChatMessage.vue'
import AIChatInput from './input/AIChatInput.vue'
import AIThinkingIndicator from './chat/AIThinkingIndicator.vue'
import ChatStatusBar from './chat/ChatStatusBar.vue'
import ChatHeaderActions from './chat/ChatHeaderActions.vue'
import { useAIChat } from '@/composables/useAIChat'
import { useAIService, useLLMService } from '@/services'
import AssistantInlineBar from './assistant/AssistantInlineBar.vue'
@@ -45,7 +46,12 @@ const props = defineProps<{
chatType?: 'group' | 'private'
}>()
const emit = defineEmits<{
'restore-loading-change': [loading: boolean]
}>()
const initialAIChatId = typeof route.query.aiChatId === 'string' ? route.query.aiChatId : null
if (initialAIChatId) emit('restore-loading-change', true)
// 使用 AI 对话 Composable
const {
@@ -92,10 +98,13 @@ async function syncAIChatIdToRoute(aiChatId: string | null): Promise<void> {
}
void initialization.finally(() => {
emit('restore-loading-change', false)
isAIChatInitialized = true
void syncAIChatIdToRoute(currentAIChatId.value)
})
onUnmounted(() => emit('restore-loading-change', false))
watch(currentAIChatId, (aiChatId) => {
if (isAIChatInitialized) void syncAIChatIdToRoute(aiChatId)
})
@@ -445,7 +454,7 @@ watch(
<div class="relative flex min-w-[480px] flex-1 flex-col overflow-hidden">
<!-- 顶部:有消息时显示助手切换按钮 -->
<template v-if="messages.length > 0 || isAIThinking">
<div class="flex items-center gap-1.5 px-3 py-1.5">
<div class="flex items-center justify-between gap-2 px-3 py-1.5">
<button
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="isAIThinking || !assistantStore.selectedAssistant?.id"
@@ -455,6 +464,12 @@ watch(
<UIcon name="i-heroicons-sparkles" class="h-3.5 w-3.5" />
<span>{{ assistantStore.selectedAssistant?.name || t('ai.assistant.fallbackName') }}</span>
</button>
<ChatHeaderActions
:current-ai-chat-id="currentAIChatId"
:current-messages="messages"
:fallback-title="sessionName"
/>
</div>
</template>
@@ -606,10 +621,10 @@ watch(
</div>
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="px-4 pb-3">
<div class="mx-auto max-w-3xl">
<div
class="overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5 dark:focus-within:ring-primary-500/40"
class="relative overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5 dark:focus-within:ring-primary-500/40"
>
<AIChatInput
ref="chatInputRef"
@@ -625,12 +640,9 @@ watch(
/>
<ChatStatusBar
class="px-2 pb-1.5 pt-0.5"
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-ai-chat-id="currentAIChatId"
:current-messages="messages"
:fallback-title="sessionName"
:estimated-context-tokens="estimatedContextTokens"
/>
</div>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import type { ChatMessage } from '@/composables/useAIChat'
import { useToast } from '@/composables/useToast'
import { useAIService } from '@/services'
import { useCacheService } from '@/services/cache/service'
import { usePromptStore } from '@/stores/prompt'
import {
exportConversation,
getExportableConversationMessages,
hasExportableConversationMessages,
type ConversationExportSourceMessage,
type ExportFormat,
} from '@/utils/conversationExport'
const props = defineProps<{
currentAIChatId?: string | null
currentMessages?: ChatMessage[]
fallbackTitle?: string
}>()
const { t } = useI18n()
const toast = useToast()
const promptStore = usePromptStore()
const { aiGlobalSettings } = storeToRefs(promptStore)
const isExporting = ref(false)
const isOpeningLog = ref(false)
const visibleExportMessages = computed(() => getExportableConversationMessages(props.currentMessages ?? []))
const canExportConversation = computed(() => {
return Boolean(props.currentAIChatId) || hasExportableConversationMessages(props.currentMessages ?? [])
})
function getExportLabels() {
return {
createdAt: t('ai.chat.conversation.export.createdAt'),
user: t('ai.chat.conversation.export.user'),
assistant: t('ai.chat.conversation.export.assistant'),
}
}
function toExportSourceMessages(messages: ConversationExportSourceMessage[]): ConversationExportSourceMessage[] {
return messages.map((message) => ({
...message,
timestamp: message.timestamp * 1000,
}))
}
async function handleExportConversation() {
if (isExporting.value || !canExportConversation.value) return
isExporting.value = true
try {
const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat
const labels = getExportLabels()
let title = props.fallbackTitle || t('ai.chat.conversation.newChat')
let createdAt = visibleExportMessages.value[0]?.timestamp ?? Date.now()
let messages = visibleExportMessages.value
if (props.currentAIChatId) {
const [conversation, persistedMessages] = await Promise.all([
useAIService().getAIChat(props.currentAIChatId),
useAIService().getMessages(props.currentAIChatId),
])
if (conversation) {
title = conversation.title || title
createdAt = conversation.createdAt * 1000
}
const persistedExportMessages = getExportableConversationMessages(toExportSourceMessages(persistedMessages))
if (persistedExportMessages.length > 0) {
messages = persistedExportMessages
}
}
if (messages.length === 0) {
toast.warn(t('ai.chat.conversation.export.noMessages'))
return
}
const result = await exportConversation(title, messages, createdAt, format, labels)
if (result.success && result.filePath) {
const filename = result.filePath.split('/').pop() || result.filePath
const exportedFilePath = result.filePath
toast.add({
title: t('common.exportSuccess'),
description: filename,
color: 'primary',
actions: [
{
label: t('common.openFolder'),
onClick: () => {
useCacheService().showInFolder(exportedFilePath)
},
},
],
})
} else {
toast.fail(t('common.exportFailed'), { description: result.error })
}
} catch (error) {
console.error('Failed to export AI conversation:', error)
toast.fail(t('common.exportFailed'), { description: String(error) })
} finally {
isExporting.value = false
}
}
async function openAiLogFile() {
if (isOpeningLog.value) return
isOpeningLog.value = true
try {
const result = await useAIService().showAiLogFile()
if (!result?.success) {
toast.fail(t('ai.chat.statusBar.log.openFailed'), {
description: result?.error || t('ai.chat.statusBar.log.openFailedDesc'),
})
}
} catch (error) {
console.error('Failed to open AI log file:', error)
toast.fail(t('ai.chat.statusBar.log.openFailed'), { description: String(error) })
} finally {
isOpeningLog.value = false
}
}
</script>
<template>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:title="t('ai.chat.statusBar.export.title')"
:disabled="isExporting || !canExportConversation"
@click="handleExportConversation"
>
<UIcon name="i-heroicons-arrow-down-tray" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.statusBar.export.label') }}</span>
</button>
<button
type="button"
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:title="t('ai.chat.statusBar.log.title')"
:disabled="isOpeningLog"
@click="openAiLogFile"
>
<UIcon name="i-heroicons-document-text" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.statusBar.log.label') }}</span>
</button>
</div>
</template>
+27 -156
View File
@@ -2,45 +2,27 @@
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
import { useLLMStore } from '@/stores/llm'
import {
exportConversation,
getExportableConversationMessages,
hasExportableConversationMessages,
type ConversationExportSourceMessage,
type ExportFormat,
} from '@/utils/conversationExport'
import type { AgentRuntimeStatus } from '@electron/shared/types'
import { useAIService } from '@/services'
import { getSupportedThinkingLevels, type ThinkingLevel } from '@openchatlab/core'
import { useCacheService } from '@/services/cache/service'
import type { ChatMessage } from '@/composables/useAIChat'
const { t } = useI18n()
const toast = useToast()
const layoutStore = useLayoutStore()
// Props
const props = defineProps<{
sessionTokenUsage: { totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number }
agentStatus?: AgentRuntimeStatus | null
currentAIChatId?: string | null
currentMessages?: ChatMessage[]
fallbackTitle?: string
estimatedContextTokens?: number
}>()
// Store
const promptStore = usePromptStore()
const llmStore = useLLMStore()
const { aiGlobalSettings } = storeToRefs(promptStore)
const { defaultAssistantConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore)
const isOpeningLog = ref(false)
const agentPhaseText = computed(() => {
if (!props.agentStatus) return ''
return t(`ai.chat.statusBar.agent.phase.${props.agentStatus.phase}`)
@@ -105,11 +87,10 @@ const contextUsagePercent = computed(() => {
return Math.min(100, Math.round((contextTokens.value / modelContextWindow.value) * 100))
})
const contextBarColor = computed(() => {
const pct = contextUsagePercent.value
if (pct >= 80) return 'bg-red-500'
if (pct >= 60) return 'bg-amber-500'
return 'bg-emerald-500'
const contextRingRadius = 5.5
const contextRingCircumference = 2 * Math.PI * contextRingRadius
const contextRingDasharray = computed(() => {
return `${(contextRingCircumference * contextUsagePercent.value) / 100} ${contextRingCircumference}`
})
const agentCompactTitle = computed(() => {
@@ -125,109 +106,6 @@ function openModelSettings() {
layoutStore.openSettings('ai', 'defaultModel')
}
// 导出当前对话
const isExporting = ref(false)
const visibleExportMessages = computed(() => getExportableConversationMessages(props.currentMessages ?? []))
const canExportConversation = computed(() => {
return Boolean(props.currentAIChatId) || hasExportableConversationMessages(props.currentMessages ?? [])
})
function getExportLabels() {
return {
createdAt: t('ai.chat.conversation.export.createdAt'),
user: t('ai.chat.conversation.export.user'),
assistant: t('ai.chat.conversation.export.assistant'),
}
}
function toExportSourceMessages(messages: ConversationExportSourceMessage[]): ConversationExportSourceMessage[] {
return messages.map((message) => ({
...message,
timestamp: message.timestamp * 1000,
}))
}
async function handleExportConversation() {
if (isExporting.value || !canExportConversation.value) return
isExporting.value = true
try {
const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat
const labels = getExportLabels()
let title = props.fallbackTitle || t('ai.chat.conversation.newChat')
let createdAt = visibleExportMessages.value[0]?.timestamp ?? Date.now()
let messages = visibleExportMessages.value
if (props.currentAIChatId) {
const [conv, persistedMessages] = await Promise.all([
useAIService().getAIChat(props.currentAIChatId),
useAIService().getMessages(props.currentAIChatId),
])
if (conv) {
title = conv.title || title
createdAt = conv.createdAt * 1000
}
const persistedExportMessages = getExportableConversationMessages(toExportSourceMessages(persistedMessages))
if (persistedExportMessages.length > 0) {
messages = persistedExportMessages
}
}
if (messages.length === 0) {
toast.warn(t('ai.chat.conversation.export.noMessages'))
return
}
const result = await exportConversation(title, messages, createdAt, format, labels)
if (result.success && result.filePath) {
const filename = result.filePath.split('/').pop() || result.filePath
const exportedFilePath = result.filePath
toast.add({
title: t('common.exportSuccess'),
description: filename,
color: 'primary',
actions: [
{
label: t('common.openFolder'),
onClick: () => {
useCacheService().showInFolder(exportedFilePath)
},
},
],
})
} else {
toast.fail(t('common.exportFailed'), { description: result.error })
}
} catch (error) {
console.error('导出对话失败:', error)
toast.fail(t('common.exportFailed'), { description: String(error) })
} finally {
isExporting.value = false
}
}
// 打开当前 AI 日志文件并定位到文件
async function openAiLogFile() {
if (isOpeningLog.value) return
isOpeningLog.value = true
try {
const result = await useAIService().showAiLogFile()
if (!result?.success) {
toast.fail(t('ai.chat.statusBar.log.openFailed'), {
description: result?.error || t('ai.chat.statusBar.log.openFailedDesc'),
})
}
} catch (error) {
console.error('打开 AI 日志失败:', error)
toast.fail(t('ai.chat.statusBar.log.openFailed'), { description: String(error) })
} finally {
isOpeningLog.value = false
}
}
// ── Thinking level selector ───────────────────────────────────────────────────
const isThinkingPopoverOpen = ref(false)
@@ -340,19 +218,33 @@ const thinkingLevelLabel = computed(() => {
<span>{{ agentPhaseShortText }}</span>
</div>
<!-- Context 进度条 -->
<!-- Context occupancy ring: geometry matches the DSH composer meter. -->
<UTooltip v-if="contextTokens > 0" :ui="{ content: 'h-auto py-1.5' }">
<div
class="hidden shrink-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-gray-400 dark:text-gray-500 md:flex"
class="hidden h-7 w-7 shrink-0 place-items-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 md:grid dark:text-gray-500 dark:hover:bg-gray-800"
:aria-label="`${t('ai.chat.statusBar.agent.contextTokens')} ${contextUsagePercent}%`"
>
<div class="h-1.5 w-10 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full transition-all duration-300"
:class="contextBarColor"
:style="{ width: `${contextUsagePercent}%` }"
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
<circle
cx="7"
cy="7"
:r="contextRingRadius"
fill="none"
class="stroke-gray-300 dark:stroke-gray-700"
stroke-width="2"
/>
</div>
<span class="text-[10px]">{{ contextUsagePercent }}%</span>
<circle
cx="7"
cy="7"
:r="contextRingRadius"
fill="none"
class="stroke-gray-400 transition-[stroke-dasharray] duration-300 dark:stroke-gray-500"
stroke-width="2"
stroke-linecap="round"
:stroke-dasharray="contextRingDasharray"
transform="rotate(-90 7 7)"
/>
</svg>
</div>
<template #content>
<div class="space-y-0.5 whitespace-nowrap text-xs">
@@ -365,27 +257,6 @@ const thinkingLevelLabel = computed(() => {
</div>
</template>
</UTooltip>
<!-- 导出按钮 -->
<button
class="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:title="t('ai.chat.statusBar.export.title')"
:disabled="isExporting || !canExportConversation"
@click="handleExportConversation"
>
<UIcon name="i-heroicons-arrow-down-tray" class="h-3.5 w-3.5" />
<span class="hidden xl:inline">{{ t('ai.chat.statusBar.export.label') }}</span>
</button>
<!-- 日志按钮 -->
<button
class="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:title="t('ai.chat.statusBar.log.title')"
:disabled="isOpeningLog"
@click="openAiLogFile"
>
<UIcon name="i-heroicons-document-text" class="h-3.5 w-3.5" />
<span class="hidden xl:inline">{{ t('ai.chat.statusBar.log.label') }}</span>
</button>
</div>
</div>
</template>
@@ -88,8 +88,8 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
: '',
]"
>
<div class="relative px-4 pt-2.5 pb-2.5">
<div class="flex items-start gap-2 pr-10">
<div class="px-4 pt-2.5 pb-2.5" :class="{ relative: !embedded }">
<div class="flex items-start gap-2" :class="{ 'pr-10': !embedded }">
<div
v-if="activeSkillName"
class="inline-flex max-w-[180px] shrink-0 items-center rounded-md bg-primary-50 px-2 text-sm leading-6 font-medium text-primary-700 dark:bg-primary-500/10 dark:text-primary-400"
+1 -1
View File
@@ -652,7 +652,7 @@ defineExpose({
<template>
<div class="shrink-0" :class="[props.embedded ? '' : 'pt-2 pb-2']">
<div ref="rootRef" class="relative w-full max-w-4xl mx-auto">
<div ref="rootRef" class="w-full max-w-4xl mx-auto" :class="{ relative: !props.embedded }">
<SlashCommandMenu
:visible="showSlashMenu"
:skills="filteredSkills"
+6 -2
View File
@@ -98,6 +98,9 @@ const {
provide('session-switch-loading', isSessionSwitching)
const isAIChatRestoring = ref(activeTab.value === 'ai-chat' && typeof route.query.aiChatId === 'string')
const showSessionLoading = computed(() => isSessionSwitching.value || isAIChatRestoring.value)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -112,7 +115,7 @@ const filteredMemberCount = computed(() => {
<template>
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
v-if="isSessionSwitching"
v-if="showSessionLoading"
data-testid="group-chat-switch-loading"
class="absolute inset-0 z-20 flex cursor-wait items-center justify-center bg-page-bg dark:bg-page-dark"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
@@ -146,7 +149,7 @@ const filteredMemberCount = computed(() => {
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<LoadingState v-if="isLoading && !showSessionLoading" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
@@ -176,6 +179,7 @@ const filteredMemberCount = computed(() => {
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="group"
@restore-loading-change="isAIChatRestoring = $event"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"
+6 -2
View File
@@ -92,6 +92,9 @@ const {
provide('session-switch-loading', isSessionSwitching)
const isAIChatRestoring = ref(activeTab.value === 'ai-chat' && typeof route.query.aiChatId === 'string')
const showSessionLoading = computed(() => isSessionSwitching.value || isAIChatRestoring.value)
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -132,7 +135,7 @@ const otherMemberAvatar = computed(() => {
<template>
<div class="relative flex h-full flex-col dark:bg-page-dark" style="padding-top: var(--titlebar-area-height)">
<div
v-if="isSessionSwitching"
v-if="showSessionLoading"
data-testid="private-chat-switch-loading"
class="absolute inset-0 z-20 flex cursor-wait items-center justify-center bg-page-bg dark:bg-page-dark"
:style="{ paddingTop: 'var(--titlebar-area-height)' }"
@@ -165,7 +168,7 @@ const otherMemberAvatar = computed(() => {
<!-- Tab Content -->
<div class="relative flex-1 overflow-y-auto">
<!-- Loading Overlay -->
<LoadingState v-if="isLoading && !isSessionSwitching" variant="overlay" :text="t('common.loading')" />
<LoadingState v-if="isLoading && !showSessionLoading" variant="overlay" :text="t('common.loading')" />
<div class="h-full">
<Transition name="tab-slide" mode="out-in">
@@ -189,6 +192,7 @@ const otherMemberAvatar = computed(() => {
:session-id="currentSessionId!"
:session-name="session.name"
chat-type="private"
@restore-loading-change="isAIChatRestoring = $event"
/>
<MemoryTab
v-else-if="activeTab === 'memory'"