mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
2109 lines
62 KiB
Vue
2109 lines
62 KiB
Vue
<template>
|
||
<div class="mc-page-shell chat-console-shell">
|
||
<div class="mc-page-frame chat-console-frame">
|
||
<div class="chat-layout mc-surface-card">
|
||
<!-- 移动端会话面板遮罩 -->
|
||
<Transition name="fade">
|
||
<div v-if="isMobile && convPanelOpen" class="conv-backdrop" @click="convPanelOpen = false"></div>
|
||
</Transition>
|
||
|
||
<!-- 会话侧边栏 -->
|
||
<div class="conversation-panel" :class="{ 'mobile-open': convPanelOpen, 'conv-collapsed': convPanelCollapsed && !isMobile }">
|
||
<div class="panel-header">
|
||
<div v-if="!convPanelCollapsed || isMobile" class="panel-header-copy">
|
||
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
|
||
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
||
</div>
|
||
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
|
||
<el-icon><Plus /></el-icon>
|
||
</button>
|
||
</div>
|
||
<!-- 折叠切换按钮 -->
|
||
<button v-if="!isMobile" class="conv-collapse-btn" @click="toggleConvPanel" :title="convPanelCollapsed ? $t('common.expandSidebar') : $t('common.collapseSidebar')">
|
||
<svg v-if="!convPanelCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</button>
|
||
|
||
<div class="agent-selector">
|
||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
|
||
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🤖' }}</span>
|
||
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
|
||
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||
</button>
|
||
<Transition name="fade">
|
||
<div v-if="agentDropdownOpen" class="agent-dropdown-backdrop" @click="agentDropdownOpen = false"></div>
|
||
</Transition>
|
||
<Transition name="agent-dropdown">
|
||
<div v-if="agentDropdownOpen" class="agent-dropdown">
|
||
<div
|
||
v-for="agent in agents"
|
||
:key="agent.id"
|
||
class="agent-dropdown-item"
|
||
:class="{ active: String(agent.id) === String(selectedAgentId) }"
|
||
@click="selectAgent(agent)"
|
||
>
|
||
<span class="agent-dropdown-item__icon">{{ agent.icon || '🤖' }}</span>
|
||
<div class="agent-dropdown-item__info">
|
||
<span class="agent-dropdown-item__name">{{ agent.name }}</span>
|
||
<span class="agent-dropdown-item__desc">{{ agent.description || agent.agentType }}</span>
|
||
</div>
|
||
<span v-if="String(agent.id) === String(selectedAgentId)" class="agent-dropdown-item__check">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||
</span>
|
||
</div>
|
||
<div v-if="agents.length === 0" class="agent-dropdown-empty">{{ $t('chat.loadingAgents') }}</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<div class="conversation-list">
|
||
<template v-for="group in groupedConversations" :key="group.label">
|
||
<div v-if="!convPanelCollapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
|
||
<div
|
||
v-for="conv in group.items"
|
||
:key="conv.conversationId"
|
||
class="conv-item"
|
||
:class="{ active: currentConversationId === conv.conversationId }"
|
||
@click="selectConversation(conv)"
|
||
>
|
||
<div class="conv-icon">
|
||
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
|
||
</div>
|
||
<div v-if="!convPanelCollapsed || isMobile" class="conv-info">
|
||
<input
|
||
v-if="renamingConvId === conv.conversationId"
|
||
v-model="renameText"
|
||
class="conv-title-input"
|
||
@keydown.enter="confirmRename(conv)"
|
||
@keydown.escape="cancelRename"
|
||
@blur="confirmRename(conv)"
|
||
@click.stop
|
||
ref="renameInputRef"
|
||
/>
|
||
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">{{ conv.title }}</div>
|
||
<div class="conv-meta">
|
||
<span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span>
|
||
<span class="conv-dot">·</span>
|
||
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
|
||
</div>
|
||
</div>
|
||
<button v-if="!convPanelCollapsed || isMobile" class="conv-delete" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="conversations.length === 0" class="empty-convs">
|
||
<p>{{ $t('chat.noConversations') }}</p>
|
||
<p>{{ $t('chat.startNewChat') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主聊天区域 -->
|
||
<div
|
||
class="chat-area"
|
||
@dragenter.prevent="onDragEnter"
|
||
@dragover.prevent
|
||
@dragleave="onDragLeave"
|
||
@drop.prevent="onDrop"
|
||
>
|
||
<!-- 拖拽上传遮罩 -->
|
||
<Transition name="fade">
|
||
<div v-if="isDragging" class="drop-overlay">
|
||
<div class="drop-overlay__content">
|
||
<el-icon><UploadFilled /></el-icon>
|
||
<span>{{ $t('chat.dropToUpload') }}</span>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
<!-- 头部 -->
|
||
<div class="chat-header">
|
||
<div class="chat-header-left">
|
||
<button v-if="isMobile" class="conv-toggle-btn" @click="convPanelOpen = !convPanelOpen" :title="$t('chat.conversations')">
|
||
<el-icon><ChatDotRound /></el-icon>
|
||
</button>
|
||
<div class="chat-stage-copy" v-if="currentAgent">
|
||
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
|
||
<div class="agent-badge" :title="currentAgent.name">
|
||
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
|
||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
|
||
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
|
||
</div>
|
||
<div class="chat-header-right">
|
||
<!-- Model selector -->
|
||
<ModelSelector
|
||
v-if="eligibleModels.length > 0"
|
||
:providers="availableProviders"
|
||
:active-value="activeModelValue"
|
||
:active-label="activeModelLabel"
|
||
:saving="modelSaving"
|
||
@select="selectModel"
|
||
/>
|
||
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
|
||
<el-icon><Setting /></el-icon>
|
||
</button>
|
||
<!-- Overflow menu -->
|
||
<div class="header-overflow-wrap">
|
||
<button class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more') || 'More'">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||
</button>
|
||
<Transition name="fade">
|
||
<div v-if="headerMenuOpen" class="header-menu-backdrop" @click="headerMenuOpen = false"></div>
|
||
</Transition>
|
||
<Transition name="agent-dropdown">
|
||
<div v-if="headerMenuOpen" class="header-menu">
|
||
<button class="header-menu-item" @click="headerMenuOpen = false; goToModelSettings()">
|
||
<el-icon><Setting /></el-icon>
|
||
<span>{{ $t('chat.configModel') }}</span>
|
||
</button>
|
||
<div class="header-menu-divider"></div>
|
||
<button class="header-menu-item header-menu-item--danger" @click="handleClearMessages">
|
||
<el-icon><Delete /></el-icon>
|
||
<span>{{ $t('chat.clearMessages') }}</span>
|
||
</button>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 使用组件化的 MessageList -->
|
||
<MessageList
|
||
ref="messageListRef"
|
||
:messages="messages"
|
||
:loading="isGenerating"
|
||
:assistant-icon="currentAgent?.icon || '🤖'"
|
||
:user-icon="userInitial"
|
||
:title="showModelPrompt ? modelPromptTitle : $t('app.title')"
|
||
:subtitle="showModelPrompt ? modelPromptDesc : $t('chat.subtitle')"
|
||
:suggestions="showModelPrompt ? [] : suggestions"
|
||
@regenerate="handleRegenerate"
|
||
@suggestion-click="sendSuggestion"
|
||
@toggle-thinking="handleToggleThinking"
|
||
@approve="handleApprove"
|
||
@deny="handleDeny"
|
||
>
|
||
<!-- 自定义模型提示空状态 -->
|
||
<template v-if="showModelPrompt" #empty>
|
||
<div class="model-prompt">
|
||
<div class="model-prompt-title">{{ modelPromptTitle }}</div>
|
||
<div class="model-prompt-desc">{{ modelPromptDesc }}</div>
|
||
<button class="btn-primary" @click="goToModelSettings">{{ $t('chat.goToModelSettings') }}</button>
|
||
</div>
|
||
</template>
|
||
</MessageList>
|
||
|
||
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
|
||
<StreamLoadingBar
|
||
:is-loading="isGenerating && !showModelPrompt"
|
||
:tool-count="toolCallCount"
|
||
:completion-tokens="currentGeneratingTokens"
|
||
:prompt-tokens="currentPromptTokens"
|
||
:phase="streamPhase"
|
||
:phase-info="phaseInfo"
|
||
:running-tool-name="currentRunningToolName"
|
||
:has-queued="hasQueued"
|
||
/>
|
||
|
||
<!-- 使用组件化的 ChatInput -->
|
||
<ChatInput
|
||
ref="chatInputRef"
|
||
v-model="inputText"
|
||
:loading="isGenerating && !hasPendingApproval"
|
||
:disabled="showModelPrompt || !currentAgent"
|
||
:placeholder="$t('chat.messagePlaceholder')"
|
||
:hint="currentRuntimeModel"
|
||
:attachments="pendingAttachments"
|
||
:uploading="uploadingAttachment"
|
||
:max-length="10240"
|
||
:pending-approval="activePendingApproval"
|
||
:stream-phase="streamPhase"
|
||
:queued-message="queuedMessage"
|
||
:queue-size="queueSize"
|
||
@submit="handleSendMessage"
|
||
@stop="handleStopStream"
|
||
@cancel-queued="handleCancelQueued"
|
||
@file-select="handleFileSelect"
|
||
@attachment-remove="removeAttachment"
|
||
@approve="handleApprove"
|
||
@deny="handleDeny"
|
||
:enable-talk-mode="!!selectedAgentId"
|
||
@talk="showTalkMode = true"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Talk Mode 覆盖层 -->
|
||
<TalkMode
|
||
:visible="showTalkMode"
|
||
:agent-id="selectedAgentId"
|
||
:conversation-id="currentConversationId"
|
||
@close="showTalkMode = false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
|
||
import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
|
||
import { channelIconUrl } from '@/utils/channelSource'
|
||
import { useChat } from '@/composables/chat/useChat'
|
||
import { reconstructErrorInfo } from '@/types/chatError'
|
||
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
|
||
import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo, ChatAttachment, MessageContentPart, Message, ToolCallMeta, StreamPhase } from '@/types'
|
||
|
||
// 导入组件化组件
|
||
import MessageList from '@/components/chat/MessageList.vue'
|
||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
|
||
import TalkMode from '@/components/chat/TalkMode.vue'
|
||
import ModelSelector from '@/components/chat/ModelSelector.vue'
|
||
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
|
||
|
||
// ============ Talk Mode ============
|
||
const showTalkMode = ref(false)
|
||
|
||
// ============ 移动端 & 响应式状态 ============
|
||
const isMobile = ref(false)
|
||
const convPanelOpen = ref(false)
|
||
const convPanelCollapsed = ref(localStorage.getItem('mc-conv-collapsed') === 'true')
|
||
let mobileQuery: MediaQueryList | null = null
|
||
let mediumQuery: MediaQueryList | null = null
|
||
const userExplicitConvCollapse = ref(localStorage.getItem('mc-conv-collapsed') === 'true')
|
||
|
||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||
isMobile.value = e.matches
|
||
if (!e.matches) convPanelOpen.value = false
|
||
}
|
||
|
||
function handleConvMediumChange(e: MediaQueryListEvent | MediaQueryList) {
|
||
if (e.matches && !userExplicitConvCollapse.value) {
|
||
convPanelCollapsed.value = true
|
||
} else if (!e.matches && !userExplicitConvCollapse.value) {
|
||
convPanelCollapsed.value = false
|
||
}
|
||
}
|
||
|
||
function toggleConvPanel() {
|
||
convPanelCollapsed.value = !convPanelCollapsed.value
|
||
userExplicitConvCollapse.value = convPanelCollapsed.value
|
||
localStorage.setItem('mc-conv-collapsed', String(convPanelCollapsed.value))
|
||
}
|
||
|
||
// ============ 配置和常量 ============
|
||
const suggestions = computed(() => {
|
||
const agent = currentAgent.value
|
||
// If agent has custom suggestions (stored as newline-separated string in description etc.)
|
||
const agentSuggestions = (agent as any)?.suggestions as string | undefined
|
||
if (agentSuggestions) {
|
||
const parsed = agentSuggestions.split('\n').filter(Boolean).slice(0, 4)
|
||
if (parsed.length) return parsed
|
||
}
|
||
// Agent-type-aware defaults
|
||
if (agent?.agentType === 'plan_execute') {
|
||
return [
|
||
t('chat.suggestionPlan1', '帮我制定一个完整的项目计划'),
|
||
t('chat.suggestionPlan2', '分步骤帮我完成一个复杂任务'),
|
||
t('chat.suggestionIntro'),
|
||
t('chat.suggestionWeather'),
|
||
]
|
||
}
|
||
return [
|
||
t('chat.suggestionIntro'),
|
||
t('chat.suggestionPoem'),
|
||
t('chat.suggestionCode'),
|
||
t('chat.suggestionWeather'),
|
||
]
|
||
})
|
||
|
||
// ============ 状态 ============
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
|
||
const agents = ref<Agent[]>([])
|
||
const conversations = ref<Conversation[]>([])
|
||
const selectedAgentId = ref<string | number>('')
|
||
const currentConversationId = ref<string>('')
|
||
const inputText = ref('')
|
||
const modelSaving = ref(false)
|
||
const showModelPrompt = ref(false)
|
||
const defaultModel = ref<ModelConfig | null>(null)
|
||
const providers = ref<ProviderInfo[]>([])
|
||
const activeModels = ref<ActiveModelsInfo | null>(null)
|
||
const pendingAttachments = ref<ChatAttachment[]>([])
|
||
const uploadingAttachment = ref(false)
|
||
|
||
// Dropdowns & menus
|
||
const agentDropdownOpen = ref(false)
|
||
|
||
const headerMenuOpen = ref(false)
|
||
|
||
function selectAgent(agent: Agent) {
|
||
agentDropdownOpen.value = false
|
||
if (String(agent.id) !== String(selectedAgentId.value)) {
|
||
selectedAgentId.value = agent.id
|
||
newConversation()
|
||
}
|
||
}
|
||
|
||
async function selectModel(value: string) {
|
||
const [providerId, model] = value.split('::')
|
||
if (!providerId || !model) return
|
||
modelSaving.value = true
|
||
try {
|
||
const res: any = await modelApi.setActive({ providerId, model })
|
||
activeModels.value = res.data || { activeLlm: { providerId, model } }
|
||
await loadModelState()
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.switchModelFailed'))
|
||
} finally {
|
||
modelSaving.value = false
|
||
}
|
||
}
|
||
|
||
function handleClearMessages() {
|
||
headerMenuOpen.value = false
|
||
clearMessages()
|
||
}
|
||
|
||
// Conversation rename
|
||
const renamingConvId = ref('')
|
||
const renameText = ref('')
|
||
const renameInputRef = ref<HTMLInputElement | null>(null)
|
||
|
||
function startRename(conv: Conversation) {
|
||
renamingConvId.value = conv.conversationId
|
||
renameText.value = conv.title || ''
|
||
nextTick(() => {
|
||
renameInputRef.value?.focus()
|
||
renameInputRef.value?.select()
|
||
})
|
||
}
|
||
|
||
async function confirmRename(conv: Conversation) {
|
||
const newTitle = renameText.value.trim()
|
||
renamingConvId.value = ''
|
||
if (!newTitle || newTitle === conv.title) return
|
||
conv.title = newTitle
|
||
try {
|
||
await conversationApi.rename(conv.conversationId, newTitle)
|
||
} catch {
|
||
// revert on fail — reload
|
||
await loadConversations()
|
||
}
|
||
}
|
||
|
||
function cancelRename() {
|
||
renamingConvId.value = ''
|
||
}
|
||
|
||
// Delete with confirmation
|
||
function confirmDeleteConversation(conversationId: string) {
|
||
ElMessageBox.confirm(
|
||
t('chat.deleteConfirm') || 'Delete this conversation?',
|
||
t('common.confirm'),
|
||
{ type: 'warning', confirmButtonText: t('common.confirm'), cancelButtonText: t('common.cancel') }
|
||
).then(() => deleteConversation(conversationId)).catch(() => {})
|
||
}
|
||
|
||
// 拖拽上传
|
||
const isDragging = ref(false)
|
||
let dragCounter = 0
|
||
|
||
function onDragEnter(e: DragEvent) {
|
||
dragCounter++
|
||
if (e.dataTransfer?.types.includes('Files')) {
|
||
isDragging.value = true
|
||
}
|
||
}
|
||
|
||
function onDragLeave() {
|
||
dragCounter--
|
||
if (dragCounter === 0) {
|
||
isDragging.value = false
|
||
}
|
||
}
|
||
|
||
async function onDrop(e: DragEvent) {
|
||
dragCounter = 0
|
||
isDragging.value = false
|
||
|
||
const dtFiles = Array.from(e.dataTransfer?.files || [])
|
||
const items = Array.from(e.dataTransfer?.items || [])
|
||
|
||
const electronDirs: File[] = []
|
||
const webDirEntries: FileSystemDirectoryEntry[] = []
|
||
const regularFiles: File[] = []
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const entry = items[i].webkitGetAsEntry?.()
|
||
const file = dtFiles[i]
|
||
if (entry?.isDirectory) {
|
||
if ((file as any)?.path) {
|
||
// Electron: has absolute path
|
||
electronDirs.push(file)
|
||
} else {
|
||
// Web: need to recursively collect files
|
||
webDirEntries.push(entry as FileSystemDirectoryEntry)
|
||
}
|
||
} else if (file) {
|
||
regularFiles.push(file)
|
||
}
|
||
}
|
||
|
||
// Electron directories → record path reference
|
||
if (electronDirs.length) {
|
||
handleDirectoryAttach(electronDirs)
|
||
}
|
||
// Web directories → recursively collect files and upload
|
||
if (webDirEntries.length) {
|
||
const collected = await collectFilesFromEntries(webDirEntries)
|
||
if (collected.length) {
|
||
handleFileSelect(collected)
|
||
}
|
||
}
|
||
// Regular files → normal upload
|
||
if (regularFiles.length) {
|
||
handleFileSelect(regularFiles)
|
||
}
|
||
}
|
||
|
||
function handleDirectoryAttach(dirFiles: File[]) {
|
||
if (!currentConversationId.value) {
|
||
newConversation()
|
||
}
|
||
for (const dir of dirFiles) {
|
||
const dirPath = (dir as any).path as string
|
||
pendingAttachments.value.push({
|
||
name: dirPath.split('/').pop() || dir.name,
|
||
size: 0,
|
||
url: '',
|
||
storedName: '',
|
||
path: dirPath,
|
||
contentType: 'inode/directory',
|
||
})
|
||
}
|
||
}
|
||
|
||
async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]): Promise<File[]> {
|
||
const files: File[] = []
|
||
|
||
async function readDir(dir: FileSystemDirectoryEntry) {
|
||
const reader = dir.createReader()
|
||
let batch: FileSystemEntry[]
|
||
do {
|
||
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||
reader.readEntries(resolve, reject)
|
||
})
|
||
for (const entry of batch) {
|
||
if (entry.isFile) {
|
||
const file = await new Promise<File>((resolve, reject) => {
|
||
(entry as FileSystemFileEntry).file(resolve, reject)
|
||
})
|
||
files.push(file)
|
||
} else if (entry.isDirectory) {
|
||
await readDir(entry as FileSystemDirectoryEntry)
|
||
}
|
||
}
|
||
} while (batch.length > 0) // readEntries returns empty when done
|
||
}
|
||
|
||
for (const dir of dirEntries) {
|
||
await readDir(dir)
|
||
}
|
||
return files
|
||
}
|
||
|
||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
|
||
|
||
// ECharts: extract DOM element from MessageList component ref
|
||
const echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
|
||
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
|
||
|
||
// 使用 useChat composable
|
||
const {
|
||
messages,
|
||
isGenerating,
|
||
streamPhase,
|
||
phaseInfo,
|
||
queuedMessage,
|
||
hasQueued,
|
||
queueSize,
|
||
heartbeat,
|
||
sendMessage: sendChatMessage,
|
||
stopGeneration: stopChatGeneration,
|
||
cancelQueued,
|
||
reconnectStream: reconnectChatStream,
|
||
resetForNewConversation,
|
||
} = useChat({
|
||
baseUrl: '',
|
||
onStreamEnd: async (meta) => {
|
||
// 流结束后刷新会话列表(更新 lastActiveTime / 标题等)
|
||
await loadConversations()
|
||
if (meta.conversationId && meta.conversationId === currentConversationId.value) {
|
||
// 审批挂起或中断续跑时不从 DB 刷新消息,避免覆盖本地状态或破坏消息顺序
|
||
if (meta.reason !== 'awaiting_approval' && meta.reason !== 'interrupted') {
|
||
await refreshCurrentConversationMessages(meta.conversationId)
|
||
}
|
||
}
|
||
},
|
||
})
|
||
|
||
// ============ 连接状态 ============
|
||
const connectionStatusClass = computed(() => {
|
||
if (isGenerating.value) return 'status-streaming'
|
||
if (streamPhase.value === 'failed') return 'status-error'
|
||
return 'status-idle'
|
||
})
|
||
const connectionStatusLabel = computed(() => {
|
||
if (isGenerating.value) return t('chat.status.streaming', 'Generating...')
|
||
if (streamPhase.value === 'failed') return t('chat.status.error', 'Disconnected')
|
||
return t('chat.status.idle', 'Ready')
|
||
})
|
||
|
||
// ============ 计算属性 ============
|
||
const currentAgent = computed(() => agents.value.find(a => String(a.id) === String(selectedAgentId.value)))
|
||
|
||
// 按日期分组的会话列表
|
||
const groupedConversations = computed(() => {
|
||
const now = new Date()
|
||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||
const yesterdayStart = todayStart - 86400000
|
||
const last7Start = todayStart - 7 * 86400000
|
||
|
||
const groups: { label: string; items: Conversation[] }[] = [
|
||
{ label: t('chat.dateToday'), items: [] },
|
||
{ label: t('chat.dateYesterday'), items: [] },
|
||
{ label: t('chat.dateLast7Days'), items: [] },
|
||
{ label: t('chat.dateEarlier'), items: [] },
|
||
]
|
||
|
||
for (const conv of conversations.value) {
|
||
const ts = conv.lastActiveTime ? new Date(conv.lastActiveTime).getTime() : 0
|
||
if (ts >= todayStart) groups[0].items.push(conv)
|
||
else if (ts >= yesterdayStart) groups[1].items.push(conv)
|
||
else if (ts >= last7Start) groups[2].items.push(conv)
|
||
else groups[3].items.push(conv)
|
||
}
|
||
|
||
return groups.filter(g => g.items.length > 0)
|
||
})
|
||
|
||
const currentRuntimeModel = computed(() => {
|
||
if (defaultModel.value?.name && defaultModel.value?.modelName) {
|
||
return `${defaultModel.value.name} (${defaultModel.value.modelName})`
|
||
}
|
||
return currentAgent.value?.modelName || 'default'
|
||
})
|
||
|
||
const userInitial = computed(() => (localStorage.getItem('username') || 'U').charAt(0).toUpperCase())
|
||
|
||
const activeModelValue = computed(() => {
|
||
const providerId = activeModels.value?.activeLlm?.providerId
|
||
const model = activeModels.value?.activeLlm?.model
|
||
return providerId && model ? `${providerId}::${model}` : ''
|
||
})
|
||
|
||
const activeModelLabel = computed(() => {
|
||
if (!activeModelValue.value) return ''
|
||
const match = eligibleModels.value.find(m => m.value === activeModelValue.value)
|
||
return match?.label || ''
|
||
})
|
||
|
||
const activeProvider = computed(() => {
|
||
const providerId = activeModels.value?.activeLlm?.providerId
|
||
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
|
||
})
|
||
|
||
const modelPromptTitle = computed(() => {
|
||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
||
return t('chat.configModelFirst')
|
||
}
|
||
if (activeProvider.value && !activeProvider.value.available) {
|
||
return t('chat.modelUnavailable')
|
||
}
|
||
return t('chat.configModelFirst')
|
||
})
|
||
|
||
const modelPromptDesc = computed(() => {
|
||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
||
return t('chat.noActiveModel')
|
||
}
|
||
if (activeProvider.value && !activeProvider.value.available) {
|
||
return t('chat.providerNotReady', { name: activeProvider.value.name })
|
||
}
|
||
return t('chat.noAvailableModel')
|
||
})
|
||
|
||
const availableProviders = computed(() =>
|
||
providers.value.filter((p) => p.available && [...(p.models || []), ...(p.extraModels || [])].length > 0)
|
||
)
|
||
|
||
const eligibleModels = computed(() => {
|
||
return availableProviders.value.flatMap((provider) => {
|
||
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
||
return allModels.map((model) => ({
|
||
value: `${provider.id}::${model.id}`,
|
||
label: `${provider.name} / ${model.name || model.id}`,
|
||
}))
|
||
})
|
||
})
|
||
|
||
// ============ 生命周期 ============
|
||
function handleKeyboardShortcuts(e: KeyboardEvent) {
|
||
const mod = e.metaKey || e.ctrlKey
|
||
if (mod && e.key === 'n') {
|
||
e.preventDefault()
|
||
newConversation()
|
||
chatInputRef.value?.focus?.()
|
||
}
|
||
if (mod && e.key === 'k') {
|
||
e.preventDefault()
|
||
agentDropdownOpen.value = !agentDropdownOpen.value
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
document.addEventListener('keydown', handleKeyboardShortcuts)
|
||
document.addEventListener('click', handleCodeCopy)
|
||
startECharts()
|
||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||
handleMobileChange(mobileQuery)
|
||
mobileQuery.addEventListener('change', handleMobileChange)
|
||
mediumQuery = window.matchMedia('(max-width: 1200px)')
|
||
handleConvMediumChange(mediumQuery)
|
||
mediumQuery.addEventListener('change', handleConvMediumChange)
|
||
await Promise.all([loadAgents(), loadModelState(), loadConversations()])
|
||
await hydrateStateFromRoute()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('keydown', handleKeyboardShortcuts)
|
||
document.removeEventListener('click', handleCodeCopy)
|
||
disposeECharts()
|
||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||
mediumQuery?.removeEventListener('change', handleConvMediumChange)
|
||
stopChatGeneration()
|
||
// 释放所有附件的 ObjectURL,防止内存泄漏
|
||
revokeAllPreviewUrls()
|
||
})
|
||
|
||
watch(() => route.query, () => {
|
||
void hydrateStateFromRoute()
|
||
})
|
||
|
||
watch([selectedAgentId, currentConversationId], () => {
|
||
syncRouteState()
|
||
})
|
||
|
||
// ============ 方法 ============
|
||
async function loadAgents() {
|
||
try {
|
||
const res: any = await agentApi.list()
|
||
agents.value = res.data || []
|
||
// 只有在 URL 没有指定 agentId 且当前无选中时,才默认选第一个
|
||
if (agents.value.length > 0 && !selectedAgentId.value && !route.query.agentId) {
|
||
selectedAgentId.value = agents.value[0].id
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.loadAgentsFailed'))
|
||
}
|
||
}
|
||
|
||
async function loadModelState() {
|
||
try {
|
||
const [defaultRes, providersRes, activeRes]: any = await Promise.all([
|
||
modelApi.getDefault(),
|
||
modelApi.listProviders(),
|
||
modelApi.getActive(),
|
||
])
|
||
defaultModel.value = defaultRes.data || null
|
||
providers.value = providersRes.data || []
|
||
activeModels.value = activeRes.data || null
|
||
const providerId = activeModels.value?.activeLlm?.providerId
|
||
const activeProviderInfo = providerId
|
||
? providers.value.find((provider) => provider.id === providerId)
|
||
: null
|
||
showModelPrompt.value = !activeModels.value?.activeLlm?.providerId
|
||
|| !activeModels.value?.activeLlm?.model
|
||
|| (Boolean(providerId) && !activeProviderInfo?.available)
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.loadModelFailed'))
|
||
showModelPrompt.value = true
|
||
}
|
||
}
|
||
|
||
async function loadConversations() {
|
||
try {
|
||
const res: any = await conversationApi.list()
|
||
conversations.value = res.data || []
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.loadConversationsFailed'))
|
||
}
|
||
}
|
||
|
||
async function refreshCurrentConversationMessages(conversationId: string) {
|
||
if (!conversationId) return
|
||
if (isGenerating.value) return
|
||
if (streamPhase.value === 'awaiting_approval') return
|
||
try {
|
||
const res: any = await conversationApi.listMessages(conversationId)
|
||
// Stale guard:await 返回后确认仍是当前会话
|
||
if (currentConversationId.value !== conversationId) return
|
||
// 二次 isGenerating 检查:如果 await 期间用户已发新消息,不覆盖本地状态
|
||
if (isGenerating.value) return
|
||
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
|
||
// 严格过滤:只保留 conversationId 完全匹配的本地消息,orphan(空 conversationId)直接丢弃
|
||
const currentMessages = messages.value.filter(
|
||
(m: any) => m.conversationId === conversationId
|
||
)
|
||
messages.value = reconcileMessages(currentMessages, fetched)
|
||
} catch (e) {
|
||
console.warn('[ChatConsole] Failed to refresh current conversation messages:', e)
|
||
}
|
||
}
|
||
|
||
async function hydrateStateFromRoute() {
|
||
const agentId = route.query.agentId ? String(route.query.agentId) : ''
|
||
const conversationId = String(route.query.conversationId || '')
|
||
|
||
if (agentId && agentId !== String(selectedAgentId.value)) {
|
||
selectedAgentId.value = agentId
|
||
}
|
||
|
||
if (conversationId && conversationId !== currentConversationId.value) {
|
||
const matchedConversation = conversations.value.find(conv => conv.conversationId === conversationId)
|
||
if (matchedConversation) {
|
||
await selectConversation(matchedConversation)
|
||
} else {
|
||
// 会话不在已加载列表中(可能来自 Sessions 页面跳转),尝试加载消息
|
||
currentConversationId.value = conversationId
|
||
messages.value = []
|
||
try {
|
||
const res: any = await conversationApi.listMessages(conversationId)
|
||
if (currentConversationId.value !== conversationId) return
|
||
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
|
||
} catch {
|
||
// 消息加载失败,保持空
|
||
}
|
||
try {
|
||
if (currentConversationId.value !== conversationId) return
|
||
const statusRes: any = await conversationApi.getStatus(conversationId)
|
||
if (currentConversationId.value === conversationId && statusRes.data?.streamStatus === 'running') {
|
||
await reconnectStream(conversationId)
|
||
}
|
||
} catch {
|
||
// 忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果仍然没选中 agent,默认选第一个
|
||
if (!selectedAgentId.value && agents.value.length > 0) {
|
||
selectedAgentId.value = agents.value[0].id
|
||
}
|
||
}
|
||
|
||
function syncRouteState() {
|
||
const query: Record<string, string> = {}
|
||
if (selectedAgentId.value) query.agentId = String(selectedAgentId.value)
|
||
if (currentConversationId.value) query.conversationId = currentConversationId.value
|
||
router.replace({ path: '/chat', query })
|
||
}
|
||
|
||
async function selectConversation(conv: Conversation) {
|
||
if (isMobile.value) convPanelOpen.value = false
|
||
resetStreamingState()
|
||
currentConversationId.value = conv.conversationId
|
||
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
||
const requestedConvId = conv.conversationId
|
||
try {
|
||
const res: any = await conversationApi.listMessages(requestedConvId)
|
||
// Stale guard:await 返回后确认仍是当前会话,否则丢弃
|
||
if (currentConversationId.value !== requestedConvId) return
|
||
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
|
||
|
||
// Hydrate pending approvals:恢复刷新后丢失的审批卡片
|
||
try {
|
||
const approvalRes: any = await chatApi.getPendingApprovals(requestedConvId)
|
||
if (currentConversationId.value !== requestedConvId) return
|
||
const pendingApprovals = approvalRes.data || []
|
||
if (pendingApprovals.length > 0) {
|
||
const assistantMessages = messages.value.filter(m => m.role === 'assistant')
|
||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||
if (lastAssistant) {
|
||
for (const pa of pendingApprovals) {
|
||
(lastAssistant as any).metadata = {
|
||
...(lastAssistant as any).metadata,
|
||
currentPhase: 'awaiting_approval',
|
||
pendingApproval: {
|
||
pendingId: pa.pendingId,
|
||
toolName: pa.toolName,
|
||
arguments: pa.toolArguments,
|
||
reason: pa.reason,
|
||
status: 'pending_approval',
|
||
findings: pa.findingsJson ? JSON.parse(pa.findingsJson) : undefined,
|
||
maxSeverity: pa.maxSeverity || undefined,
|
||
summary: pa.summary || undefined,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// hydration 失败不影响正常使用
|
||
}
|
||
|
||
if (currentConversationId.value === requestedConvId && conv.streamStatus === 'running') {
|
||
await reconnectStream(requestedConvId)
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.loadMessagesFailed'))
|
||
}
|
||
}
|
||
|
||
function newConversation() {
|
||
resetStreamingState()
|
||
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
messages.value = []
|
||
}
|
||
|
||
async function deleteConversation(conversationId: string) {
|
||
try {
|
||
await conversationApi.delete(conversationId)
|
||
conversations.value = conversations.value.filter(c => c.conversationId !== conversationId)
|
||
if (currentConversationId.value === conversationId) {
|
||
resetStreamingState()
|
||
messages.value = []
|
||
currentConversationId.value = ''
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.deleteConversationFailed'))
|
||
}
|
||
}
|
||
|
||
async function clearMessages() {
|
||
if (!currentConversationId.value) return
|
||
try {
|
||
resetStreamingState()
|
||
await conversationApi.clearMessages(currentConversationId.value)
|
||
messages.value = []
|
||
} catch {
|
||
messages.value = []
|
||
}
|
||
}
|
||
|
||
// onAgentChange removed — replaced by selectAgent()
|
||
|
||
// onModelChange removed — replaced by selectModel()
|
||
|
||
function goToModelSettings() {
|
||
router.push('/settings/models')
|
||
}
|
||
|
||
// ============ 计算属性:是否有待审批 ============
|
||
const hasPendingApproval = computed(() => {
|
||
return messages.value.some(
|
||
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
|
||
)
|
||
})
|
||
|
||
// 当前待审批的那条数据(传给 ChatInput 用于渲染审批栏)
|
||
const activePendingApproval = computed(() => {
|
||
const msg = messages.value.findLast(
|
||
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
|
||
)
|
||
return (msg as any)?.metadata?.pendingApproval ?? null
|
||
})
|
||
|
||
// 当前工具调用数
|
||
const toolCallCount = computed(() => {
|
||
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
|
||
return lastMsg?.metadata?.toolCalls?.length ?? 0
|
||
})
|
||
|
||
// 当前正在执行的工具名称
|
||
const currentRunningToolName = computed(() => {
|
||
if (!isGenerating.value) return ''
|
||
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
|
||
const metadata = lastMsg?.metadata
|
||
if (metadata?.runningToolName) return metadata.runningToolName
|
||
const runningTool = metadata?.toolCalls?.findLast((tc: any) => tc.status === 'running')
|
||
return runningTool?.name || heartbeat.value?.runningToolName || ''
|
||
})
|
||
|
||
// 当前正在生成的消息的 token 数据
|
||
const currentGeneratingTokens = computed(() => {
|
||
if (!isGenerating.value) return 0
|
||
// 找到最后一条 assistant 消息(可能仍在生成)
|
||
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
|
||
// 返回 completionTokens(从服务器响应中获取)
|
||
return (lastMsg as any)?.completionTokens ?? 0
|
||
})
|
||
|
||
const currentPromptTokens = computed(() => {
|
||
if (!isGenerating.value) return 0
|
||
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
|
||
return (lastMsg as any)?.promptTokens ?? 0
|
||
})
|
||
|
||
// ============ 消息发送和处理 ============
|
||
async function handleSendMessage(content: string) {
|
||
// 允许在等待审批时发送审批命令
|
||
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
|
||
|
||
if ((!content && pendingAttachments.value.length === 0) || !selectedAgentId.value || showModelPrompt.value) return
|
||
// 不再阻止运行中发送 — useChat 会自动走 interrupt/queue 路径
|
||
|
||
// 拦截 /approve 和 /deny 命令 —— 通过 SSE 流发送(和普通消息相同通道)
|
||
const trimmed = content.trim().toLowerCase()
|
||
if (trimmed === '/approve' || trimmed === '/deny') {
|
||
if (!currentConversationId.value) {
|
||
ElMessage.warning('No active conversation')
|
||
inputText.value = ''
|
||
chatInputRef.value?.clear?.()
|
||
return
|
||
}
|
||
|
||
// 检查是否有 pending approval
|
||
const pendingMsg = messages.value.findLast(
|
||
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
|
||
)
|
||
if (!pendingMsg) {
|
||
ElMessage.warning('No pending approval to process')
|
||
inputText.value = ''
|
||
chatInputRef.value?.clear?.()
|
||
return
|
||
}
|
||
|
||
// 乐观更新审批状态
|
||
const decision = trimmed === '/approve' ? 'approved' : 'denied'
|
||
;(pendingMsg as any).metadata.pendingApproval.status = decision
|
||
|
||
inputText.value = ''
|
||
chatInputRef.value?.clear?.()
|
||
|
||
// 通过正常 SSE 流发送(复用聊天通道,replay 结果实时流式推送)
|
||
try {
|
||
await sendChatMessage(trimmed, {
|
||
conversationId: currentConversationId.value,
|
||
agentId: selectedAgentId.value,
|
||
contentParts: [],
|
||
})
|
||
} catch (e: any) {
|
||
console.error('Approval stream failed:', e)
|
||
// 回滚乐观更新
|
||
;(pendingMsg as any).metadata.pendingApproval.status = 'pending_approval'
|
||
ElMessage.error(e?.message || 'Approval failed')
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!currentConversationId.value) {
|
||
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
}
|
||
|
||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
||
showModelPrompt.value = true
|
||
return
|
||
}
|
||
if (!activeProvider.value?.available) {
|
||
showModelPrompt.value = true
|
||
return
|
||
}
|
||
|
||
const outgoingAttachments = pendingAttachments.value.map((attachment) => ({ ...attachment }))
|
||
const contentParts = buildOutgoingParts(content, outgoingAttachments)
|
||
|
||
// 先暂存,发送成功后再清空(失败时恢复)
|
||
const savedInput = inputText.value
|
||
const savedAttachments = [...pendingAttachments.value]
|
||
inputText.value = ''
|
||
chatInputRef.value?.clear?.()
|
||
pendingAttachments.value = []
|
||
|
||
try {
|
||
await sendChatMessage(content, {
|
||
conversationId: currentConversationId.value,
|
||
agentId: selectedAgentId.value,
|
||
contentParts,
|
||
attachments: outgoingAttachments.map(a => ({
|
||
type: 'file' as const,
|
||
fileUrl: a.url,
|
||
fileName: a.name,
|
||
storedName: a.storedName,
|
||
contentType: a.contentType,
|
||
fileSize: a.size,
|
||
path: a.path,
|
||
})),
|
||
})
|
||
// 发送成功后释放 ObjectURL
|
||
revokeAllPreviewUrls()
|
||
} catch (e) {
|
||
console.error('Send message failed:', e)
|
||
// 发送失败:恢复输入和附件,用户不丢失已上传的文件
|
||
if (!inputText.value) inputText.value = savedInput
|
||
if (pendingAttachments.value.length === 0) pendingAttachments.value = savedAttachments
|
||
}
|
||
}
|
||
|
||
function handleStopStream() {
|
||
stopChatGeneration()
|
||
}
|
||
|
||
function handleRegenerate(message: Message) {
|
||
if (isGenerating.value) return
|
||
const idx = messages.value.indexOf(message)
|
||
if (idx >= 0) {
|
||
messages.value.splice(idx, 1)
|
||
}
|
||
const lastUserMsg = messages.value.findLast(m => m.role === 'user')
|
||
if (!lastUserMsg) return
|
||
|
||
const text = lastUserMsg.contentParts
|
||
.filter(p => p.type === 'text')
|
||
.map(p => p.text || '')
|
||
.join('\n') || lastUserMsg.content || ''
|
||
|
||
handleSendMessage(text)
|
||
}
|
||
|
||
function sendSuggestion(text: string) {
|
||
inputText.value = text
|
||
handleSendMessage(text)
|
||
}
|
||
|
||
function handleToggleThinking(message: import('@/types').Message, expanded: boolean) {
|
||
message.thinkingExpanded = expanded
|
||
}
|
||
|
||
// ============ 审批处理 ============
|
||
async function handleApprove(pendingId: string) {
|
||
if (!currentConversationId.value) return
|
||
await handleSendMessage('/approve')
|
||
}
|
||
|
||
async function handleDeny(pendingId: string) {
|
||
if (!currentConversationId.value) return
|
||
await handleSendMessage('/deny')
|
||
}
|
||
|
||
// 重连到运行中的流
|
||
async function reconnectStream(conversationId: string) {
|
||
if (isGenerating.value) return
|
||
try {
|
||
await reconnectChatStream(conversationId)
|
||
} catch (e) {
|
||
console.error('[ChatConsole] Reconnect failed:', e)
|
||
ElMessage.warning(t('chat.reconnectFailed') || 'Stream reconnection failed')
|
||
}
|
||
}
|
||
|
||
function handleCancelQueued() {
|
||
cancelQueued()
|
||
}
|
||
|
||
// 简化版重置函数
|
||
function resetStreamingState() {
|
||
// 先通知后端停止旧流(fire-and-forget),再彻底清理前端状态
|
||
stopChatGeneration()
|
||
resetForNewConversation()
|
||
}
|
||
|
||
// ============ 附件处理 ============
|
||
async function handleFileSelect(files: File[]) {
|
||
if (!currentConversationId.value) {
|
||
newConversation()
|
||
}
|
||
|
||
uploadingAttachment.value = true
|
||
try {
|
||
for (const file of files) {
|
||
const res: any = await chatApi.uploadFile(currentConversationId.value, file)
|
||
const data = res.data || {}
|
||
// 图片/视频使用本地 ObjectURL 预览(避免 /api/v1/chat/files/ 需要 JWT 认证导致加载失败)
|
||
const ct = data.contentType || file.type || ''
|
||
const isPreviewable = ct.startsWith('image/') || ct.startsWith('video/')
|
||
const previewUrl = isPreviewable ? URL.createObjectURL(file) : data.url
|
||
pendingAttachments.value.push({
|
||
name: data.fileName || file.name,
|
||
size: data.size || file.size,
|
||
url: data.url,
|
||
storedName: data.storedName,
|
||
path: data.path,
|
||
contentType: data.contentType || file.type,
|
||
previewUrl,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(t('chat.uploadFailed'))
|
||
} finally {
|
||
uploadingAttachment.value = false
|
||
}
|
||
}
|
||
|
||
function removeAttachment(key: string) {
|
||
// revoke 被移除附件的 ObjectURL,防止内存泄漏
|
||
const removed = pendingAttachments.value.find(a => a.storedName === key || a.path === key)
|
||
if (removed?.previewUrl?.startsWith('blob:')) {
|
||
URL.revokeObjectURL(removed.previewUrl)
|
||
}
|
||
pendingAttachments.value = pendingAttachments.value.filter(
|
||
a => a.storedName !== key && a.path !== key
|
||
)
|
||
}
|
||
|
||
/** 释放所有 pending 附件的 ObjectURL */
|
||
function revokeAllPreviewUrls() {
|
||
for (const a of pendingAttachments.value) {
|
||
if (a.previewUrl?.startsWith('blob:')) {
|
||
URL.revokeObjectURL(a.previewUrl)
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildOutgoingParts(text: string, attachments: ChatAttachment[]): MessageContentPart[] {
|
||
const parts: MessageContentPart[] = []
|
||
if (text) parts.push({ type: 'text', text })
|
||
for (const attachment of attachments) {
|
||
const ct = attachment.contentType || ''
|
||
const partType: MessageContentPart['type'] = ct.startsWith('video/') ? 'video'
|
||
: ct.startsWith('image/') ? 'image'
|
||
: 'file'
|
||
parts.push({
|
||
type: partType,
|
||
fileUrl: attachment.url,
|
||
fileName: attachment.name,
|
||
storedName: attachment.storedName,
|
||
contentType: attachment.contentType,
|
||
fileSize: attachment.size,
|
||
path: attachment.path,
|
||
})
|
||
}
|
||
return parts
|
||
}
|
||
|
||
// ============ 工具函数 ============
|
||
function normalizeMessage(raw: Message): Message {
|
||
const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] }
|
||
|
||
// 统一解析 metadata:确保是对象而非 JSON 字符串
|
||
// 注意:后端 metadata 在 DB 中是 JSON 字符串,Jackson 序列化时可能双重编码
|
||
if (typeof msg.metadata === 'string') {
|
||
try {
|
||
let parsed = JSON.parse(msg.metadata)
|
||
// 处理双重编码:parse 后仍然是字符串的情况
|
||
if (typeof parsed === 'string') {
|
||
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
|
||
}
|
||
msg.metadata = parsed
|
||
} catch { msg.metadata = {} as any }
|
||
}
|
||
|
||
// 保留后端返回的 token 字段(MessageVO 新增)
|
||
if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens
|
||
if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens
|
||
|
||
if (msg.contentParts.length === 0 && msg.content) {
|
||
if (msg.role === 'assistant') {
|
||
const parsed = parseThinkingContent(msg.content)
|
||
if (parsed.thinking) msg.contentParts.push({ type: 'thinking', text: parsed.thinking })
|
||
if (parsed.content) msg.contentParts.push({ type: 'text', text: parsed.content })
|
||
msg.content = parsed.content
|
||
} else {
|
||
msg.contentParts.push({ type: 'text', text: msg.content })
|
||
}
|
||
}
|
||
|
||
// 从 tool_call contentParts 还原 metadata.toolCalls
|
||
const toolCallParts = msg.contentParts.filter(p => p.type === 'tool_call')
|
||
if (toolCallParts.length > 0) {
|
||
const toolCalls: ToolCallMeta[] = []
|
||
for (const part of toolCallParts) {
|
||
try {
|
||
const parsed = JSON.parse(part.text || '{}')
|
||
toolCalls.push({
|
||
name: parsed.name || '',
|
||
arguments: parsed.arguments,
|
||
result: parsed.result,
|
||
success: parsed.success,
|
||
// 历史消息中不应有 running 状态的工具调用(流已结束)
|
||
status: 'completed',
|
||
})
|
||
} catch {
|
||
// skip malformed tool_call parts
|
||
}
|
||
}
|
||
if (toolCalls.length > 0) {
|
||
msg.metadata = { ...msg.metadata, toolCalls }
|
||
}
|
||
msg.contentParts = msg.contentParts.filter(p => p.type !== 'tool_call')
|
||
}
|
||
|
||
// 历史消息的 metadata.toolCalls 也需要清理 running 状态
|
||
if (msg.metadata?.toolCalls) {
|
||
const cleaned = msg.metadata.toolCalls.map((tc: ToolCallMeta) => ({
|
||
...tc,
|
||
status: tc.status === 'running' ? 'completed' as const : tc.status,
|
||
}))
|
||
msg.metadata = { ...msg.metadata, toolCalls: cleaned }
|
||
}
|
||
|
||
if (msg.status === 'generating') msg.status = 'failed'
|
||
// interrupted 是合法的历史状态(interrupt-with-followup),不映射为 stopped
|
||
if (!msg.status) msg.status = 'completed'
|
||
|
||
// 从 file/image/video contentParts 恢复 attachments(历史消息 API 不返回单独的 attachments 字段)
|
||
const fileParts = msg.contentParts.filter(p => (p.type === 'file' || p.type === 'image' || p.type === 'video') && p.fileUrl)
|
||
if (fileParts.length > 0 && (!msg.attachments || msg.attachments.length === 0)) {
|
||
msg.attachments = fileParts.map(p => ({
|
||
name: p.fileName || 'unknown',
|
||
size: typeof p.fileSize === 'number' ? p.fileSize : Number(p.fileSize) || 0,
|
||
url: p.fileUrl!,
|
||
storedName: p.storedName || '',
|
||
path: p.path || '',
|
||
contentType: p.contentType,
|
||
}))
|
||
}
|
||
|
||
// 从持久化的 [错误] 文本重建 errorInfo,使刷新后也能显示错误卡片
|
||
if (msg.role === 'assistant' && !msg.errorInfo) {
|
||
const text = msg.content || msg.contentParts?.find(p => p.type === 'text')?.text || ''
|
||
const rebuilt = reconstructErrorInfo(text)
|
||
if (rebuilt) {
|
||
msg.errorInfo = rebuilt
|
||
msg.status = 'failed'
|
||
}
|
||
}
|
||
|
||
return msg
|
||
}
|
||
|
||
function parseThinkingContent(raw: string): { content: string; thinking: string; hasThinking: boolean } {
|
||
if (!raw) return { content: '', thinking: '', hasThinking: false }
|
||
|
||
const normalized = raw.replace(/<thinking>/gi, '<think>').replace(/<\/thinking>/gi, '</think>')
|
||
const thinkingParts: string[] = []
|
||
const content = normalized.replace(/<think>([\s\S]*?)<\/think>/gi, (_, thinkingText: string) => {
|
||
const cleanText = thinkingText.trim()
|
||
if (cleanText) thinkingParts.push(cleanText)
|
||
return ''
|
||
}).trim()
|
||
|
||
return {
|
||
content,
|
||
thinking: thinkingParts.join('\n\n').trim(),
|
||
hasThinking: thinkingParts.length > 0,
|
||
}
|
||
}
|
||
|
||
function formatConversationTime(time?: string) {
|
||
if (!time) return t('chat.timeJustNow')
|
||
const date = new Date(time)
|
||
const diff = Date.now() - date.getTime()
|
||
if (diff < 60 * 60 * 1000) return t('chat.timeMinutesAgo', { n: Math.max(1, Math.floor(diff / (60 * 1000))) })
|
||
if (diff < 24 * 60 * 60 * 1000) return t('chat.timeHoursAgo', { n: Math.floor(diff / (60 * 60 * 1000)) })
|
||
return date.toLocaleDateString()
|
||
}
|
||
|
||
function handleCodeCopy(e: MouseEvent) {
|
||
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
|
||
if (!btn) return
|
||
const encoded = btn.getAttribute('data-code')
|
||
if (!encoded) return
|
||
const code = decodeURIComponent(encoded)
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
btn.classList.add('copied')
|
||
const textEl = btn.querySelector('.code-block__copy-text')
|
||
if (textEl) textEl.textContent = t('chat.copied')
|
||
setTimeout(() => {
|
||
btn.classList.remove('copied')
|
||
if (textEl) textEl.textContent = t('chat.copy')
|
||
}, 1500)
|
||
}).catch(() => {
|
||
ElMessage.error(t('chat.copyFailed'))
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-console-shell {
|
||
background: transparent;
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chat-console-frame {
|
||
height: min(calc(100vh - 28px), 100%);
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chat-layout {
|
||
display: flex;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.conversation-panel {
|
||
width: 248px;
|
||
min-width: 248px;
|
||
background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
|
||
border-right: 1px solid var(--mc-border-light);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
transition: width 0.25s ease, min-width 0.25s ease;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed {
|
||
width: 54px;
|
||
min-width: 54px;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed .panel-header {
|
||
justify-content: center;
|
||
padding: 14px 8px 12px;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed .agent-selector {
|
||
padding: 10px 6px 12px;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed .agent-select-trigger {
|
||
justify-content: center;
|
||
padding: 8px;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed .conv-item {
|
||
justify-content: center;
|
||
padding: 10px 6px;
|
||
}
|
||
|
||
.conversation-panel.conv-collapsed .conv-icon {
|
||
margin: 0;
|
||
}
|
||
|
||
.conv-collapse-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 28px;
|
||
border: none;
|
||
border-bottom: 1px solid var(--mc-border-light);
|
||
background: transparent;
|
||
color: var(--mc-text-tertiary);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.conv-collapse-btn:hover {
|
||
background: var(--mc-bg-muted);
|
||
color: var(--mc-text-primary);
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 14px 12px;
|
||
border-bottom: 1px solid var(--mc-border-light);
|
||
}
|
||
|
||
.panel-header-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.panel-kicker {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--mc-accent);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--mc-text-primary);
|
||
margin: 0;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
|
||
.new-chat-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid var(--mc-border);
|
||
background: var(--mc-panel-raised);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--mc-text-primary);
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.new-chat-btn:hover {
|
||
background: var(--mc-primary);
|
||
border-color: var(--mc-primary);
|
||
color: white;
|
||
}
|
||
|
||
.agent-selector {
|
||
padding: 10px 12px 12px;
|
||
border-bottom: 1px solid var(--mc-border-light);
|
||
position: relative;
|
||
}
|
||
|
||
.agent-select-trigger {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 12px;
|
||
font-size: 13px;
|
||
color: var(--mc-text-primary);
|
||
background: var(--mc-bg-sunken);
|
||
cursor: pointer;
|
||
outline: none;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.agent-select-trigger:hover {
|
||
border-color: var(--mc-primary);
|
||
background: var(--mc-bg-elevated);
|
||
}
|
||
|
||
.agent-select-trigger__icon {
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.agent-select-trigger__name {
|
||
flex: 1;
|
||
text-align: left;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.agent-select-trigger__arrow {
|
||
flex-shrink: 0;
|
||
color: var(--mc-text-tertiary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.agent-select-trigger__arrow.open {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.agent-dropdown-backdrop,
|
||
.model-dropdown-backdrop,
|
||
.header-menu-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 99;
|
||
}
|
||
|
||
.agent-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 12px;
|
||
right: 12px;
|
||
z-index: 100;
|
||
background: var(--mc-bg-elevated);
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 14px;
|
||
padding: 6px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.agent-dropdown-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.agent-dropdown-item:hover {
|
||
background: var(--mc-bg-sunken);
|
||
}
|
||
|
||
.agent-dropdown-item.active {
|
||
background: var(--mc-primary-bg);
|
||
}
|
||
|
||
.agent-dropdown-item__icon {
|
||
font-size: 24px;
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.agent-dropdown-item__info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.agent-dropdown-item__name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mc-text-primary);
|
||
}
|
||
|
||
.agent-dropdown-item__desc {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.agent-dropdown-item__check {
|
||
flex-shrink: 0;
|
||
color: var(--mc-primary);
|
||
}
|
||
|
||
.agent-dropdown-empty {
|
||
padding: 16px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--mc-text-tertiary);
|
||
}
|
||
|
||
.agent-dropdown-enter-active {
|
||
transition: all 0.15s ease-out;
|
||
}
|
||
.agent-dropdown-leave-active {
|
||
transition: all 0.1s ease-in;
|
||
}
|
||
.agent-dropdown-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(-6px) scale(0.97);
|
||
}
|
||
.agent-dropdown-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px) scale(0.98);
|
||
}
|
||
|
||
.conversation-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.conv-group-title {
|
||
padding: 10px 10px 6px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: var(--mc-text-tertiary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
}
|
||
|
||
.conv-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 11px;
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.conv-item:hover {
|
||
background: var(--mc-bg-sunken);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.conv-item.active {
|
||
background: var(--mc-primary-bg);
|
||
}
|
||
|
||
.conv-item:hover .conv-delete {
|
||
opacity: 1;
|
||
}
|
||
|
||
.conv-icon {
|
||
color: var(--mc-text-tertiary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.conv-item.active .conv-icon {
|
||
color: var(--mc-primary);
|
||
}
|
||
|
||
.conv-info {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.conv-title {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--mc-text-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.conv-item.active .conv-title {
|
||
color: var(--mc-primary);
|
||
}
|
||
|
||
.conv-meta {
|
||
font-size: 11px;
|
||
color: var(--mc-text-tertiary);
|
||
margin-top: 1px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.conv-dot {
|
||
color: var(--mc-text-tertiary);
|
||
}
|
||
|
||
.conv-title-input {
|
||
width: 100%;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--mc-text-primary);
|
||
background: var(--mc-bg-elevated);
|
||
border: 1px solid var(--mc-primary);
|
||
border-radius: 6px;
|
||
padding: 2px 6px;
|
||
outline: none;
|
||
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15);
|
||
}
|
||
|
||
.conv-delete {
|
||
opacity: 0;
|
||
width: 22px;
|
||
height: 22px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: var(--mc-text-tertiary);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.conv-delete:hover {
|
||
background: var(--mc-danger-bg);
|
||
color: var(--mc-danger);
|
||
}
|
||
|
||
.empty-convs {
|
||
text-align: center;
|
||
padding: 32px 16px;
|
||
color: var(--mc-text-tertiary);
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.chat-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg));
|
||
position: relative;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 拖拽上传遮罩 */
|
||
.drop-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(217, 119, 87, 0.06);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.drop-overlay__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 40px 60px;
|
||
border: 2px dashed var(--mc-primary, #D97757);
|
||
border-radius: 16px;
|
||
background: var(--mc-bg-elevated, #f8fafc);
|
||
color: var(--mc-primary, #D97757);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.chat-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 16px;
|
||
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
|
||
border-bottom: 1px solid var(--mc-border);
|
||
min-height: 52px;
|
||
backdrop-filter: blur(12px);
|
||
gap: 10px;
|
||
}
|
||
|
||
.chat-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
gap: 10px;
|
||
}
|
||
|
||
.chat-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chat-stage-copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.chat-stage-kicker {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--mc-accent);
|
||
}
|
||
|
||
.agent-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 5px 10px;
|
||
background: var(--mc-primary-bg);
|
||
border-radius: 999px;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.agent-badge-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.agent-badge-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mc-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.agent-badge-type {
|
||
font-size: 11px;
|
||
color: var(--mc-primary-light);
|
||
background: var(--mc-bg-elevated);
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;
|
||
transition: background 0.3s;
|
||
}
|
||
.status-idle { background: #34d399; box-shadow: 0 0 4px rgba(52, 211, 153, 0.5); }
|
||
.status-streaming { background: #fbbf24; box-shadow: 0 0 4px rgba(251, 191, 36, 0.5); animation: pulse-dot 1.2s infinite; }
|
||
.status-error { background: #f87171; box-shadow: 0 0 4px rgba(248, 113, 113, 0.5); }
|
||
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||
|
||
.no-agent-hint {
|
||
font-size: 13px;
|
||
color: var(--mc-text-tertiary);
|
||
}
|
||
|
||
/* Model selector */
|
||
/* Model selector styles moved to ModelSelector.vue */
|
||
|
||
/* Header overflow menu */
|
||
.header-overflow-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.header-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
right: 0;
|
||
z-index: 100;
|
||
min-width: 180px;
|
||
background: var(--mc-bg-elevated);
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.header-menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
border: none;
|
||
background: none;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
color: var(--mc-text-primary);
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.header-menu-item:hover {
|
||
background: var(--mc-bg-sunken);
|
||
}
|
||
|
||
.header-menu-item--danger:hover {
|
||
background: var(--mc-danger-bg);
|
||
color: var(--mc-danger);
|
||
}
|
||
|
||
.header-menu-divider {
|
||
height: 1px;
|
||
background: var(--mc-border-light);
|
||
margin: 2px 8px;
|
||
}
|
||
|
||
.header-btn {
|
||
width: 30px;
|
||
height: 30px;
|
||
border: 1px solid var(--mc-border);
|
||
background: var(--mc-panel-raised);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--mc-text-secondary);
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.header-btn:hover {
|
||
border-color: var(--mc-danger);
|
||
color: var(--mc-danger);
|
||
}
|
||
|
||
.model-prompt {
|
||
margin: 24px auto 0;
|
||
max-width: 540px;
|
||
padding: 20px;
|
||
background: var(--mc-bg-elevated);
|
||
border: 1px solid var(--mc-border);
|
||
border-radius: 16px;
|
||
text-align: center;
|
||
box-shadow: 0 8px 24px rgba(124, 63, 30, 0.06);
|
||
}
|
||
|
||
.model-prompt-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--mc-text-primary);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.model-prompt-desc {
|
||
font-size: 14px;
|
||
color: var(--mc-text-secondary);
|
||
line-height: 1.6;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.btn-primary {
|
||
padding: 8px 16px;
|
||
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
|
||
color: white;
|
||
border: none;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(--mc-primary-hover);
|
||
}
|
||
|
||
/* ===== 移动端元素(桌面端隐藏) ===== */
|
||
.conv-backdrop {
|
||
display: none;
|
||
}
|
||
|
||
.conv-toggle-btn {
|
||
display: none;
|
||
}
|
||
|
||
/* ===== 移动端适配 ===== */
|
||
@media (max-width: 768px) {
|
||
.chat-console-shell {
|
||
padding: 0 !important;
|
||
height: 100dvh !important;
|
||
height: 100vh !important;
|
||
overflow: hidden !important;
|
||
min-height: 0 !important;
|
||
}
|
||
|
||
.chat-console-frame {
|
||
height: 100% !important;
|
||
min-height: 0 !important;
|
||
overflow: hidden !important;
|
||
border-radius: 0;
|
||
border: none;
|
||
}
|
||
|
||
.conversation-panel {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 100;
|
||
width: 272px;
|
||
min-width: 272px;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.25s ease;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.conversation-panel.mobile-open {
|
||
transform: translateX(0);
|
||
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.conv-backdrop {
|
||
display: block;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 99;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.conv-toggle-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid var(--mc-border);
|
||
background: var(--mc-bg-elevated);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
color: var(--mc-text-secondary);
|
||
flex-shrink: 0;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.conv-toggle-btn:hover {
|
||
border-color: var(--mc-primary);
|
||
color: var(--mc-primary);
|
||
}
|
||
|
||
.chat-header {
|
||
padding: 9px 12px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.agent-badge {
|
||
padding: 4px 8px;
|
||
}
|
||
|
||
.chat-stage-kicker {
|
||
display: none;
|
||
}
|
||
|
||
.agent-badge-name,
|
||
.agent-badge-type {
|
||
display: none;
|
||
}
|
||
|
||
.model-select-trigger {
|
||
max-width: 160px;
|
||
}
|
||
|
||
.model-dropdown {
|
||
min-width: 200px;
|
||
}
|
||
|
||
.drop-overlay__content {
|
||
padding: 24px 32px;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.chat-header {
|
||
padding: 6px 8px;
|
||
min-height: 44px;
|
||
}
|
||
|
||
.chat-header-right {
|
||
gap: 4px;
|
||
}
|
||
|
||
.model-select-trigger {
|
||
max-width: 120px;
|
||
height: 30px;
|
||
padding: 0 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.header-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
}
|
||
}
|
||
|
||
</style>
|