mateclaw/mateclaw-ui/src/views/ChatConsole.vue

1383 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-layout">
<!-- 会话侧边栏 -->
<div class="conversation-panel">
<div class="panel-header">
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
<button class="new-chat-btn" @click="newConversation" :title="$t('chat.newChat')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div class="agent-selector">
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange">
<option v-if="agents.length === 0" value="">{{ $t('chat.loadingAgents') }}</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
{{ agent.icon || '🤖' }} {{ agent.name }}
</option>
</select>
</div>
<div class="conversation-list">
<template v-for="group in groupedConversations" :key="group.label">
<div 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 class="conv-info">
<div class="conv-title">{{ 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 class="conv-delete" @click.stop="deleteConversation(conv.conversationId)" :title="$t('common.delete')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</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">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>{{ $t('chat.dropToUpload') }}</span>
</div>
</div>
</Transition>
<!-- 头部 -->
<div class="chat-header">
<div class="chat-header-left">
<div class="agent-badge" v-if="currentAgent">
<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>
</div>
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div>
<div class="chat-header-right">
<select
v-if="eligibleModels.length > 0"
:value="activeModelValue"
class="model-select"
:disabled="modelSaving"
@change="onModelChange"
>
<option v-for="item in eligibleModels" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01A1.65 1.65 0 0 0 10.5 3.1V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button class="header-btn" @click="clearMessages" :title="$t('chat.clearMessages')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</div>
<!-- 使用组件化的 MessageList -->
<MessageList
ref="messageListRef"
:messages="messages"
:loading="isGenerating"
:assistant-icon="currentAgent?.icon || '🤖'"
:user-icon="userInitial"
:title="showModelPrompt ? modelPromptTitle : 'MateClaw'"
: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"
: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="`MateClaw · ${currentRuntimeModel}` + (currentConversationId ? ` · Session · ${currentConversationId.slice(0, 8)}...` : '')"
: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"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
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 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'
// ============ 配置和常量 ============
const suggestions = computed(() => [
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)
// 拖拽上传
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)
// 使用 useChat composable
const {
messages,
isGenerating,
streamPhase,
queuedMessage,
hasQueued,
queueSize,
heartbeat,
sendMessage: sendChatMessage,
stopGeneration: stopChatGeneration,
cancelQueued,
reconnectStream: reconnectChatStream,
} = 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 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 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 eligibleModels = computed(() => {
return providers.value
.filter((provider) => provider.available)
.flatMap((provider) => {
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
return allModels.map((model) => ({
value: `${provider.id}::${model.id}`,
label: `${provider.name} / ${model.name || model.id}`,
}))
})
})
// ============ 生命周期 ============
onMounted(async () => {
document.addEventListener('click', handleCodeCopy)
await Promise.all([loadAgents(), loadModelState(), loadConversations()])
await hydrateStateFromRoute()
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleCodeCopy)
stopChatGeneration()
})
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
// 如果已经在生成新消息(用户在 stop 后又快速发了新消息),不覆盖本地状态
if (isGenerating.value) return
// 审批挂起时本地状态比 DB 更丰富(含 thinking + text不替换
if (streamPhase.value === 'awaiting_approval') return
try {
const res: any = await conversationApi.listMessages(conversationId)
messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg))
} 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)
messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg))
} catch {
// 消息加载失败,保持空
}
try {
const statusRes: any = await conversationApi.getStatus(conversationId)
if (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) {
resetStreamingState()
currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value
try {
const res: any = await conversationApi.listMessages(conv.conversationId)
messages.value = (res.data || []).map((msg: Message) => normalizeMessage(msg))
// Hydrate pending approvals恢复刷新后丢失的审批卡片
try {
const approvalRes: any = await chatApi.getPendingApprovals(conv.conversationId)
const pendingApprovals = approvalRes.data || []
if (pendingApprovals.length > 0) {
// 将 pending approvals 绑定到最近的 assistant 消息
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',
// 增强字段Phase 6: 结构化风险信息)
findings: pa.findingsJson ? JSON.parse(pa.findingsJson) : undefined,
maxSeverity: pa.maxSeverity || undefined,
summary: pa.summary || undefined,
}
}
}
}
}
} catch {
// hydration 失败不影响正常使用
}
if (conv.streamStatus === 'running') {
await reconnectStream(conv.conversationId)
}
} 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 = []
}
}
function onAgentChange() {
newConversation()
}
async function onModelChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
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 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)
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,
})),
})
} catch (e) {
console.error('Send message failed:', e)
}
}
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() {
stopChatGeneration()
}
// ============ 附件处理 ============
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 || {}
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,
})
}
} catch (e) {
ElMessage.error(t('chat.uploadFailed'))
} finally {
uploadingAttachment.value = false
}
}
function removeAttachment(key: string) {
pendingAttachments.value = pendingAttachments.value.filter(
a => a.storedName !== key && a.path !== key
)
}
function buildOutgoingParts(text: string, attachments: ChatAttachment[]): MessageContentPart[] {
const parts: MessageContentPart[] = []
if (text) parts.push({ type: 'text', text })
for (const attachment of attachments) {
parts.push({
type: 'file',
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] : [] }
// 保留后端返回的 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'
// 从持久化的 [错误] 文本重建 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 'just now'
const date = new Date(time)
const diff = Date.now() - date.getTime()
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 1000)))}m ago`
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / (60 * 60 * 1000))}h ago`
return date.toLocaleDateString('zh-CN')
}
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-layout {
display: flex;
height: 100%;
overflow: hidden;
}
.conversation-panel {
width: 260px;
min-width: 260px;
background: var(--mc-bg-elevated);
border-right: 1px solid var(--mc-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--mc-border-light);
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
}
.new-chat-btn {
width: 28px;
height: 28px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 6px;
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;
border-bottom: 1px solid var(--mc-border-light);
}
.agent-select {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--mc-border);
border-radius: 6px;
font-size: 13px;
color: var(--mc-text-primary);
background: var(--mc-bg-sunken);
cursor: pointer;
outline: none;
}
.agent-select:focus {
border-color: var(--mc-primary);
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1);
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-group-title {
padding: 8px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.conv-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.conv-item:hover {
background: var(--mc-bg-sunken);
}
.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-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: var(--mc-chat-bg);
position: relative;
}
/* 拖拽上传遮罩 */
.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: 12px 20px;
background: var(--mc-chat-header-bg);
border-bottom: 1px solid var(--mc-border);
min-height: 52px;
}
.chat-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.agent-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--mc-primary-bg);
border-radius: 20px;
}
.agent-badge-icon {
font-size: 14px;
}
.agent-badge-name {
font-size: 13px;
font-weight: 500;
color: var(--mc-primary);
}
.agent-badge-type {
font-size: 11px;
color: var(--mc-primary-light);
background: var(--mc-bg-elevated);
padding: 1px 6px;
border-radius: 10px;
}
.no-agent-hint {
font-size: 13px;
color: var(--mc-text-tertiary);
}
.model-select {
min-width: 260px;
height: 32px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-size: 13px;
padding: 0 10px;
}
.header-btn {
width: 32px;
height: 32px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 6px;
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: var(--mc-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--mc-primary-hover);
}
</style>