Files
aiagent/frontend/src/views/AgentChat.vue
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 22:37:56 +08:00

1241 lines
63 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>
<MainLayout>
<div class="page-header">
<div class="page-header-left">
<h3>{{ chatMode === 'single' ? (agent ? agent.name : 'AI Agent 对话') : '多 Agent 编排' }}</h3>
</div>
<div class="page-header-actions">
<el-switch
v-model="chatMode"
active-value="orchestrate"
inactive-value="single"
active-text="编排"
inactive-text=" Agent"
style="margin-right: 12px"
/>
<!-- Agent 模式 -->
<template v-if="chatMode === 'single'">
<el-select v-model="currentAgentId" placeholder="选择 Agent" @change="switchAgent" style="width: 180px" clearable>
<el-option v-for="a in agents" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
<!-- Session selector with rename -->
<el-select
v-model="currentSessionId"
placeholder="对话记录"
style="width: 220px"
:disabled="!currentAgentId"
@change="switchSession"
clearable
>
<el-option label="+ 新对话" value="__new__">
<span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span>
</el-option>
<el-option
v-for="s in sessions"
:key="s.session_id"
:label="s.title || '未命名对话'"
:value="s.session_id"
>
<div style="display:flex;flex-direction:column;width:100%">
<span class="session-option-title">{{ s.title || '未命名对话' }}</span>
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
</div>
</el-option>
</el-select>
<!-- Session rename button -->
<el-button
v-if="currentSessionId && currentSessionId !== '__new__'"
text
size="small"
@click="showRenameDialog = true"
title="重命名会话"
>
<el-icon :size="16"><Edit /></el-icon>
</el-button>
<!-- Export button -->
<el-dropdown trigger="click" v-if="displayMessages.length > 0">
<el-button text size="small" title="导出对话">
<el-icon :size="16"><Download /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="exportSession('markdown')">导出 Markdown</el-dropdown-item>
<el-dropdown-item @click="exportSession('json')">导出 JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- Branches button -->
<el-button text size="small" @click="showBranchesPanel = true" title="对话分支">
<el-icon :size="16"><Share /></el-icon>
</el-button>
</template>
<!-- 编排模式 -->
<template v-if="chatMode === 'orchestrate'">
<el-select v-model="orchestrateMode" style="width: 140px">
<el-option label="辩论模式" value="debate" />
<el-option label="路由模式" value="route" />
<el-option label="顺序模式" value="sequential" />
<el-option label="流水线模式" value="pipeline" />
</el-select>
<el-button @click="showOrchestrateEditor = true" style="margin-left: 8px">
配置 Agent ({{ orchestrateAgents.length }})
</el-button>
</template>
<!-- 搜索 -->
<el-popover placement="bottom" :width="400" trigger="click" @show="searchInputRef?.focus()">
<template #reference>
<el-button text size="small" title="搜索消息 (Ctrl+F)">
<el-icon :size="16"><Search /></el-icon>
</el-button>
</template>
<div class="search-panel">
<el-input
ref="searchInputRef"
v-model="searchKeyword"
placeholder="搜索消息内容..."
clearable
@input="handleSearchMessages"
@keyup.enter="handleSearchMessages"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="search-results" v-if="searchResults.length > 0">
<div class="search-count">找到 {{ searchTotal }} 条匹配</div>
<div
v-for="r in searchResults"
:key="r.id"
class="search-result-item"
@click="jumpToSearchResult(r)"
>
<div class="search-result-header">
<el-tag size="small" :type="r.role === 'user' ? 'info' : 'success'">{{ r.role === 'user' ? '用户' : 'AI' }}</el-tag>
<span class="search-result-session">{{ r.session_title || r.session_id?.slice(0, 8) }}</span>
<span class="search-result-time">{{ relativeTimeText(r.created_at) }}</span>
</div>
<div class="search-result-content" v-html="highlightSearchMatch(r.content)"></div>
</div>
<el-pagination
v-if="searchTotal > 10"
v-model:current-page="searchPage"
:page-size="10"
:total="searchTotal"
layout="prev, next"
size="small"
@current-change="handleSearchMessages"
/>
</div>
<el-empty v-else-if="searchKeyword && searchKeyword.length >= 1 && searchDone" description="无匹配消息" :image-size="40" />
</div>
</el-popover>
<el-button @click="clearChat" :disabled="displayMessages.length === 0">清空</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesRef">
<!-- Empty state -->
<div v-if="displayMessages.length === 0 && !loading" class="chat-empty">
<template v-if="chatMode === 'single' && agent">
<el-avatar :size="56" icon="Promotion" style="background:var(--el-color-primary-light-5);margin-bottom:8px" />
<h3 class="welcome-title">{{ agent.name }}</h3>
<p v-if="agent.description" class="welcome-desc">{{ agent.description }}</p>
<div class="welcome-tags">
<el-tag size="small" type="info" v-if="agent.version">v{{ agent.version }}</el-tag>
<el-tag size="small" :type="agent.status === 'published' ? 'success' : 'warning'">{{ agent.status === 'published' ? '已发布' : agent.status }}</el-tag>
</div>
<p class="hint">可以问我任何问题或者试试下面的示例</p>
<div class="preset-questions">
<div class="preset-q" v-for="(q, qi) in getPresetQuestions()" :key="qi" @click="sendPresetQuestion(q)">{{ q }}</div>
</div>
</template>
<template v-else-if="chatMode === 'single'">
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p>选择一个智能体开始对话</p>
<p class="hint">从上方下拉框中选择已创建的 Agent</p>
<div class="agent-quick-list" v-if="agents.length > 0">
<el-button v-for="a in agents.slice(0, 6)" :key="a.id" size="small" @click="currentAgentId = a.id; switchAgent()" style="margin:4px">{{ a.name }}</el-button>
</div>
</template>
<template v-else>
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p>配置多个 Agent 后发送消息进行编排对话</p>
<p class="hint">支持辩论路由顺序流水线四种编排模式</p>
</template>
</div>
<ChatMessageBubble
v-for="(msg, i) in displayMessages"
:key="i"
:role="msg.role"
:content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)"
:timestamp="msg.timestamp"
:status="msg.status"
:iterations="msg.iterations"
:tool-calls-made="msg.tool_calls_made"
>
<template v-if="msg.orchestrateResult" #extra>
<div class="orchestrate-result">
<div class="orch-header">
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} Agent</span>
</div>
<div class="orch-final">
<div class="orch-section-title">最终回答</div>
<div class="message-text" v-html="renderMarkdown(msg.orchestrateResult.final_answer)"></div>
</div>
<div class="orch-steps">
<div v-for="(step, si) in msg.orchestrateResult.steps" :key="si" class="orch-step" :class="{ expanded: step._open }">
<div class="orch-step-header" @click="step._open = !step._open">
<el-icon><CaretRight :style="{ transform: step._open ? 'rotate(90deg)' : '' }" /></el-icon>
<el-tag size="small" :type="step.error ? 'danger' : 'success'" round>{{ step.agent_name }}</el-tag>
<span class="orch-step-meta">{{ step.iterations_used }} · {{ step.tool_calls_made }} 次工具</span>
</div>
<div v-show="step._open" class="orch-step-body">
<div class="message-text" v-html="renderMarkdown(step.output)"></div>
</div>
</div>
</div>
</div>
</template>
<template v-if="!msg.orchestrateResult" #extra>
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header"><el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})</div>
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
</div>
</div>
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(msg)">
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ msg.steps.length }} )</span>
</div>
<div v-show="msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
<div class="step-icon">
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
<el-icon v-else><Select /></el-icon>
</div>
<div class="step-body">
<div class="step-header">
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span>
<span class="step-iter">#{{ step.iteration }}</span>
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span>
</div>
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
<div v-if="step.reasoning" class="step-reasoning">
<div class="reasoning-header">推理过程</div>
<div class="reasoning-text">{{ step.reasoning }}</div>
</div>
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
<div class="reasoning-header">参数</div>
<pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
</div>
<div v-if="step.tool_result" class="step-tool-result">
<div class="reasoning-header">结果</div>
<pre>{{ step.tool_result }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<template #actions>
<el-button v-if="msg.role === 'assistant' && msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(msg.orchestrateResult?.final_answer || msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
<el-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
</el-button>
<el-button v-if="msg.role === 'user'" link size="small" @click="editMessage(i)" title="编辑"><el-icon><Edit /></el-icon></el-button>
<el-button v-if="msg.role === 'assistant'" link size="small" @click="copyMessage(msg)" title="复制"><el-icon><DocumentCopy /></el-icon></el-button>
<el-button link size="small" @click="createBranch(i)" title="从此分支"><el-icon><Share /></el-icon></el-button>
<el-button v-if="msg.status === 'error'" link type="danger" size="small" @click="retryMessage(i)" title="重试"><el-icon><Refresh /></el-icon></el-button>
<el-button link size="small" @click="deleteMessage(i)" title="删除"><el-icon style="color:var(--el-color-danger)"><Delete /></el-icon></el-button>
</template>
</ChatMessageBubble>
<div v-if="loading && !streamingActive" class="message assistant">
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
<div class="message-bubble">
<div class="thinking"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div>
</div>
</div>
<ChatInputArea
ref="inputAreaRef"
v-model="inputMessage"
:loading="loading"
:disabled="loading || (chatMode === 'single' && !currentAgentId)"
:attachments="pendingAttachments"
:drag-over="inputDragOver"
:show-stop="loading"
:is-listening="isListening"
:voice-supported="voiceSupported"
placeholder="输入你的问题...&#10;Enter 发送Shift+Enter 换行&#10;支持拖拽或粘贴图片/文件"
@send="sendMessage"
@stop="stopGeneration"
@attach-file="handleAttachFile"
@start-voice="startListening"
@stop-voice="stopListening"
@files-selected="onFilesSelected"
@remove-attachment="removeAttachment"
>
<template #extraActions>
<el-popover v-if="chatMode === 'single'" placement="top" :width="280" trigger="click">
<template #reference>
<el-button text size="small" title="对话设置"><el-icon :size="18"><Setting /></el-icon></el-button>
</template>
<div class="chat-settings-popover">
<div style="margin-bottom:8px;font-size:13px;font-weight:500">模型配置</div>
<el-select v-model="chatModelConfigId" size="small" style="width:100%" clearable placeholder="默认模型">
<el-option-group
v-for="group in modelConfigGroups"
:key="group.label"
:label="group.label"
>
<el-option
v-for="mc in group.configs"
:key="mc.id"
:label="mc.name || `${group.label} ${mc.model_name}`"
:value="mc.id"
>
<span>{{ mc.name || mc.model_name }}</span>
<span style="float:right;color:#909399;font-size:11px">{{ mc.model_name }}</span>
</el-option>
</el-option-group>
</el-select>
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">Temperature: {{ chatTemperature.toFixed(1) }}</div>
<el-slider v-model="chatTemperature" :min="0" :max="2" :step="0.1" size="small" show-stops />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">最大迭代</div>
<el-input-number v-model="chatMaxIterations" :min="1" :max="50" size="small" style="width:100%" />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">最大 Token </div>
<el-select v-model="chatMaxTokens" size="small" style="width:100%" clearable placeholder="默认">
<el-option v-for="t in tokenOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">朗读语音</div>
<el-select v-model="chatVoice" size="small" style="width:100%">
<el-option-group label="── 教师女声 ──">
<el-option label="晓晓老师 · 温柔教导" value="teacher1" />
<el-option label="晓怡老师 · 活泼教学" value="teacher2" />
</el-option-group>
<el-option-group label="── 女声 ──">
<el-option label="晓晓 · 温柔" value="xiaoxiao" />
<el-option label="晓怡 · 活泼" value="xiaoyi" />
<el-option label="晓辰 · 温和" value="xiaochen" />
<el-option label="晓涵 · 知性" value="xiaohan" />
</el-option-group>
<el-option-group label="── 男声 ──">
<el-option label="云希 · 阳光" value="yunxi" />
<el-option label="云扬 · 专业" value="yunyang" />
<el-option label="云健 · 激情" value="yunjian" />
<el-option label="云夏 · 可爱" value="yunxia" />
</el-option-group>
</el-select>
</div>
</el-popover>
</template>
</ChatInputArea>
<!-- 编排 Agent 编辑器 -->
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState">
<div class="orch-editor">
<div v-if="orchestrateMode === 'pipeline'" class="orch-mode-hint">
<el-alert type="info" :closable="false" show-icon>
<template #title>流水线模式仅使用第一个 Agent 作为执行器系统自动创建 Planner规划 Reviewer审查角色</template>
</el-alert>
</div>
<div v-for="(agt, i) in orchestrateAgents" :key="i" class="orch-agent-card">
<div class="orch-agent-header">
<span class="orch-agent-num">#{{ i + 1 }}</span>
<el-input v-model="agt.name" placeholder="名称" style="width: 140px" size="small" />
<el-input v-model="agt.id" placeholder="ID" style="width: 120px" size="small" />
<el-button size="small" type="danger" link @click="orchestrateAgents.splice(i, 1)">删除</el-button>
</div>
<el-input v-model="agt.system_prompt" type="textarea" :rows="3" placeholder="System Prompt" size="small" />
<div class="orch-agent-params">
<el-select v-model="agt.model_config_id" size="small" style="width: 180px" clearable placeholder="模型(可选)">
<el-option-group
v-for="group in modelConfigGroups"
:key="group.label"
:label="group.label"
>
<el-option
v-for="mc in group.configs"
:key="mc.id"
:label="mc.name || mc.model_name"
:value="mc.id"
/>
</el-option-group>
</el-select>
<el-input-number v-model="agt.temperature" :min="0" :max="2" :step="0.1" size="small" style="width: 110px" />
<el-input-number v-model="agt.max_iterations" :min="1" :max="50" size="small" style="width: 110px" />
</div>
</div>
<el-button @click="addOrchestrateAgent" style="width: 100%; margin-top: 8px">+ 添加 Agent</el-button>
</div>
<template #footer>
<el-button @click="showOrchestrateEditor = false">关闭</el-button>
</template>
</el-dialog>
<!-- 会话重命名对话框 -->
<el-dialog v-model="showRenameDialog" title="重命名会话" width="420px">
<el-input v-model="renameTitle" placeholder="输入新标题" @keyup.enter="handleRenameSession" />
<el-button link type="primary" size="small" style="margin-top:8px" @click="autoGenerateTitle" :loading="autoTitleLoading">
<el-icon><MagicStick /></el-icon> AI 自动生成标题
</el-button>
<template #footer>
<el-button @click="showRenameDialog = false">取消</el-button>
<el-button type="primary" @click="handleRenameSession">确定</el-button>
</template>
</el-dialog>
<!-- 对话分支面板 -->
<el-drawer v-model="showBranchesPanel" title="对话分支" size="380px" direction="rtl">
<div class="branches-panel">
<div class="branches-actions">
<el-button size="small" type="primary" @click="createBranch(-1)" :disabled="displayMessages.length === 0">
从当前位置创建分支
</el-button>
</div>
<div v-if="branches.length > 0" class="branches-list">
<div
v-for="b in branches"
:key="b.id"
class="branch-item"
:class="{ active: currentBranchId === b.id }"
@click="switchToBranch(b)"
>
<div class="branch-title">{{ b.title || '未命名分支' }}</div>
<div class="branch-meta">
<span>{{ b.agent_name || 'Agent' }}</span>
<span>{{ b.message_count }} 条消息</span>
<span>{{ relativeTimeText(b.created_at) }}</span>
</div>
<div class="branch-actions">
<el-button link size="small" type="danger" @click.stop="deleteBranch(b.id)">删除</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无分支" :image-size="48" />
<el-button style="width:100%;margin-top:12px" @click="loadBranches()">刷新</el-button>
</div>
</el-drawer>
<!-- 工具审批对话框 -->
<el-dialog v-model="showApprovalDialog" title="工具执行审批" width="480px" :close-on-click-modal="false">
<div style="margin-bottom: 12px">
<el-tag type="warning" size="large">{{ approvalToolName }}</el-tag>
<span style="margin-left: 8px; color: #666">需要人工审批才能执行</span>
</div>
<div v-if="Object.keys(approvalArgs).length > 0" style="background: #f5f7fa; padding: 12px; border-radius: 6px; max-height: 200px; overflow-y: auto">
<pre style="margin: 0; font-size: 13px; white-space: pre-wrap; word-break: break-all">{{ JSON.stringify(approvalArgs, null, 2) }}</pre>
</div>
<template #footer>
<el-button @click="resolveApproval('denied')">拒绝</el-button>
<el-button @click="resolveApproval('skip')">跳过</el-button>
<el-button type="primary" @click="resolveApproval('approved')">批准执行</el-button>
</template>
</el-dialog>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting, Search, Download, Share, MagicStick } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue'
import api from '@/api'
import type { Agent } from '@/stores/agent'
import { useModelConfigStore } from '@/stores/modelConfig'
import { useMarkdown } from '@/composables/useMarkdown'
import { useFileUpload } from '@/composables/useFileUpload'
import { useTTS } from '@/composables/useTTS'
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
const { renderMarkdown, stripMarkdownForTTS } = useMarkdown()
const { pendingAttachments, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onFilesSelected, handleAttachFile } = useFileUpload()
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
const { isListening, transcript, error: voiceError, supported: voiceSupported, start: voiceStart, stop: voiceStop } = useSpeechRecognition()
const modelConfigStore = useModelConfigStore()
interface AgentStep {
iteration: number; type: string; content: string
tool_name?: string; tool_input?: Record<string, any>; tool_result?: string; reasoning?: string
}
interface OrchestrateStep {
agent_id: string; agent_name: string; input: string; output: string
iterations_used: number; tool_calls_made: number; error?: string; _open?: boolean
}
interface OrchestrateResult {
mode: string; final_answer: string; steps: OrchestrateStep[]; agent_results: any[]
}
interface ChatMessage {
role: 'user' | 'assistant'; content: string; tool_calls?: any[]; timestamp: number
iterations?: number; tool_calls_made?: number; status?: string; steps?: AgentStep[]
_traceOpen?: boolean; orchestrateResult?: OrchestrateResult
}
interface OrchestrateAgentForm {
id: string; name: string; system_prompt: string; model: string; model_config_id: string
temperature: number; max_iterations: number; description: string
}
interface BranchInfo {
id: string; title: string; agent_name?: string; parent_session_id: string
branch_session_id: string; message_count: number; first_user_message?: string; created_at: string
}
const STORAGE_KEY = 'agent_chat_state'
interface ChatState {
messages: Record<string, ChatMessage[]>
sessionId: Record<string, string>
currentAgentId: string
chatMode: 'single' | 'orchestrate'
chatModelConfigId: string
chatTemperature: number
chatMaxIterations: number
chatMaxTokens: number | null
chatVoice: string
orchestrateMode: string
orchestrateAgents: OrchestrateAgentForm[]
_savedAt?: number
}
function saveState() {
try {
const state: any = {
messages: messages.value,
sessionId: sessionId.value,
currentAgentId: currentAgentId.value,
chatMode: chatMode.value,
chatModelConfigId: chatModelConfigId.value,
chatTemperature: chatTemperature.value,
chatMaxIterations: chatMaxIterations.value,
chatMaxTokens: chatMaxTokens.value,
chatVoice: chatVoice.value,
orchestrateMode: orchestrateMode.value,
orchestrateAgents: orchestrateAgents.value,
_savedAt: Date.now(),
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch { /* ignore */ }
}
function loadState(): ChatState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) { localStorage.removeItem(STORAGE_KEY); return null }
if (Array.isArray(parsed.messages)) {
parsed.messages = { '__bare__': parsed.messages }
parsed.sessionId = { '__bare__': parsed.sessionId || '' }
}
return parsed
} catch { return null }
}
const route = useRoute()
const agents = ref<Agent[]>([])
const currentAgentId = ref('')
const messages = ref<Record<string, ChatMessage[]>>({})
const inputMessage = ref('')
const loading = ref(false)
const streamingActive = ref(false)
const abortController = ref<AbortController | null>(null)
const inputAreaRef = ref<InstanceType<typeof ChatInputArea> | null>(null)
const showApprovalDialog = ref(false)
const approvalId = ref('')
const approvalToolName = ref('')
const approvalArgs = ref<Record<string, any>>({})
const messagesRef = ref<HTMLElement | null>(null)
const isNearBottom = ref(true)
const sessionId = ref<Record<string, string>>({})
const agent = ref<Agent | null>(null)
interface SessionInfo {
session_id: string; title?: string; last_message?: string
message_count: number; is_pinned: boolean
created_at?: string; updated_at?: string
}
const sessions = ref<SessionInfo[]>([])
const currentSessionId = ref('')
const sessionsLoading = ref(false)
// 对话设置
const chatModelConfigId = ref('')
const chatTemperature = ref(0.8)
const chatMaxIterations = ref(15)
const chatMaxTokens = ref<number | null>(null)
const tokenOptions = [
{ label: '512', value: 512 },
{ label: '1024', value: 1024 },
{ label: '2048', value: 2048 },
{ label: '4096', value: 4096 },
{ label: '8192', value: 8192 },
{ label: '16384', value: 16384 },
]
const chatVoice = ref('xiaoxiao')
// 模型配置下拉分组
interface ModelConfigGroup { label: string; configs: { id: string; name: string; model_name: string }[] }
const modelConfigGroups = ref<ModelConfigGroup[]>([])
// 会话重命名
const showRenameDialog = ref(false)
const renameTitle = ref('')
const autoTitleLoading = ref(false)
// 消息搜索
const searchKeyword = ref('')
const searchResults = ref<any[]>([])
const searchTotal = ref(0)
const searchPage = ref(1)
const searchDone = ref(false)
const searchInputRef = ref<any>(null)
// 对话分支
const showBranchesPanel = ref(false)
const branches = ref<BranchInfo[]>([])
const currentBranchId = ref('')
const currentAgentKey = computed(() => {
if (chatMode.value === 'orchestrate') return '__orchestrate__'
return currentAgentId.value || '__bare__'
})
const displayMessages = computed(() => messages.value[currentAgentKey.value] || [])
// 编排模式
const chatMode = ref<'single' | 'orchestrate'>('single')
const orchestrateMode = ref('debate')
const showOrchestrateEditor = ref(false)
const orchestrateAgents = ref<OrchestrateAgentForm[]>([
{ id: 'agent-a', name: 'Agent A', system_prompt: '你是一个有用的AI助手。', model: 'deepseek-v4-flash', model_config_id: '', temperature: 0.7, max_iterations: 10, description: '' },
{ id: 'agent-b', name: 'Agent B', system_prompt: '你是一个专业的分析助手。', model: 'deepseek-v4-flash', model_config_id: '', temperature: 0.7, max_iterations: 10, description: '' },
])
function addOrchestrateAgent() {
const n = orchestrateAgents.value.length + 1
orchestrateAgents.value.push({
id: `agent-${String.fromCharCode(96 + n)}`, name: `Agent ${String.fromCharCode(64 + n)}`,
system_prompt: '你是一个有用的AI助手。', model: 'deepseek-v4-flash', model_config_id: '',
temperature: 0.7, max_iterations: 10, description: '',
})
saveState()
}
async function fetchModelConfigs() {
try {
const configs = await modelConfigStore.fetchModelConfigs({ limit: 100 })
const groups: Record<string, ModelConfigGroup> = {}
const displayMap: Record<string, string> = {
deepseek: 'DeepSeek', openai: 'OpenAI', anthropic: 'Anthropic',
qwen: '阿里通义千问', zhipu: '智谱 GLM', baidu: '百度文心一言',
moonshot: '月之暗面 Kimi', bytedance: '字节豆包', minimax: 'MiniMax',
openrouter: 'OpenRouter', siliconflow: '硅基流动', xunfei: '讯飞星火',
hunyuan: '腾讯混元', yi: '零一万物', baichuan: '百川',
cohere: 'Cohere', xai: 'xAI', together: 'Together', fireworks: 'Fireworks',
perplexity: 'Perplexity', deepinfra: 'DeepInfra', groq: 'Groq',
google: 'Google Gemini', mistral: 'Mistral AI', local: '本地/其他',
}
for (const c of (Array.isArray(configs) ? configs : [])) {
const label = displayMap[c.provider] || c.provider?.toUpperCase() || '未知'
if (!groups[label]) groups[label] = { label, configs: [] }
groups[label].configs.push({ id: c.id, name: c.name, model_name: c.model_name })
}
modelConfigGroups.value = Object.values(groups)
} catch { /* 静默失败 */ }
}
// ── 分支管理 ──
async function loadBranches() {
try {
const resp = await api.get('/api/v1/agent-chat/branches', {
params: { agent_id: currentAgentId.value || undefined, limit: 50 },
})
branches.value = resp.data.branches || []
} catch { branches.value = [] }
}
async function createBranch(msgIdx: number) {
const key = currentAgentKey.value
if (!sessionId.value[key]) { ElMessage.warning('请先发送消息创建会话'); return }
try {
const resp = await api.post('/api/v1/agent-chat/branches', {
session_id: sessionId.value[key],
agent_id: currentAgentId.value || undefined,
})
ElMessage.success('分支已创建')
currentBranchId.value = resp.data.id
await loadBranches()
showBranchesPanel.value = true
} catch (e: any) { ElMessage.error(e.response?.data?.detail || '创建分支失败') }
}
async function switchToBranch(b: BranchInfo) {
if (!b.branch_session_id) return
currentBranchId.value = b.id
const key = currentAgentKey.value
sessionId.value[key] = b.branch_session_id
currentSessionId.value = b.branch_session_id
// Load branch messages from parent session
try {
const resp = await api.get(`/api/v1/agent-chat/branches/${b.id}`)
if (resp.data.messages) {
messages.value[key] = resp.data.messages
.filter((m: any) => m.role === 'user' || m.role === 'assistant')
.map((m: any) => ({
role: m.role, content: m.content || '',
timestamp: m.created_at ? new Date(m.created_at).getTime() : Date.now(),
}))
}
} catch { /* ignore */ }
await loadSessions()
saveState()
showBranchesPanel.value = false
nextTick(scrollToBottom)
}
async function deleteBranch(id: string) {
try {
await api.delete(`/api/v1/agent-chat/branches/${id}`)
if (currentBranchId.value === id) currentBranchId.value = ''
ElMessage.success('分支已删除')
await loadBranches()
} catch (e: any) { ElMessage.error(e.response?.data?.detail || '删除失败') }
}
// ── 会话重命名 ──
async function handleRenameSession() {
if (!currentAgentId.value || !currentSessionId.value || currentSessionId.value === '__new__') return
try {
await api.patch(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${currentSessionId.value}`, { title: renameTitle.value })
ElMessage.success('标题已更新')
showRenameDialog.value = false
await loadSessions()
saveState()
} catch (e: any) { ElMessage.error(e.response?.data?.detail || '重命名失败') }
}
async function autoGenerateTitle() {
if (displayMessages.value.length === 0) { ElMessage.warning('没有消息可生成标题'); return }
autoTitleLoading.value = true
const recentMsgs = displayMessages.value.slice(0, 6).map(m => `${m.role === 'user' ? '用户' : 'AI'}: ${(m.content || '').slice(0, 200)}`).join('\n')
try {
const resp = await api.post('/api/v1/agent-chat/bare', {
message: `请给以下对话生成一个简短的标题不超过20个字只返回标题文本不要其他内容\n${recentMsgs}`,
model: 'deepseek-v4-flash', max_iterations: 1, temperature: 0.3,
})
renameTitle.value = (resp.data.content || '').trim().slice(0, 50)
} catch { ElMessage.warning('自动生成失败') }
finally { autoTitleLoading.value = false }
}
// ── 消息搜索 ──
async function handleSearchMessages() {
if (!searchKeyword.value || searchKeyword.value.length < 1) { searchResults.value = []; searchDone.value = false; return }
if (!currentAgentId.value) return
try {
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/search-messages`, {
params: { q: searchKeyword.value, skip: (searchPage.value - 1) * 10, limit: 10 },
})
searchResults.value = resp.data.messages || []
searchTotal.value = resp.data.total || 0
searchDone.value = true
} catch { searchResults.value = []; searchDone.value = true }
}
function highlightSearchMatch(text: string) {
if (!text || !searchKeyword.value) return text || ''
const escaped = searchKeyword.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark class="search-highlight">$1</mark>')
}
async function jumpToSearchResult(r: any) {
if (r.session_id) {
sessionId.value[currentAgentKey.value] = r.session_id
currentSessionId.value = r.session_id
await switchSession(r.session_id)
}
// Close popover by removing focus
document.activeElement && (document.activeElement as HTMLElement).blur()
}
// ── 导出 ──
async function exportSession(format: string) {
if (!currentAgentId.value || !currentSessionId.value) { ElMessage.warning('请先开始对话'); return }
try {
const token = localStorage.getItem('access_token') || localStorage.getItem('token') || ''
const resp = await fetch(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${currentSessionId.value}/export?format=${format}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!resp.ok) throw new Error('Export failed')
const ext = format === 'markdown' ? 'md' : 'json'
const mime = format === 'markdown' ? 'text/markdown' : 'application/json'
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `chat_export.${ext}`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch { ElMessage.error('导出失败') }
}
async function resolveApproval(decision: string) {
try { await api.post(`/api/v1/approval/${approvalId.value}/resolve`, { decision }) } catch { /* ignore */ }
showApprovalDialog.value = false; approvalId.value = ''; approvalToolName.value = ''; approvalArgs.value = {}
}
watch(chatMode, saveState)
watch(orchestrateMode, saveState)
watch(voiceError, (err) => { if (err) ElMessage.warning(err) })
function handleScroll() {
if (!messagesRef.value) return
const el = messagesRef.value
isNearBottom.value = (el.scrollHeight - el.scrollTop - el.clientHeight) < 60
}
onMounted(async () => {
await loadAgents()
await fetchModelConfigs()
const saved = loadState()
if (saved) {
messages.value = saved.messages; sessionId.value = saved.sessionId
chatMode.value = saved.chatMode; orchestrateMode.value = saved.orchestrateMode
orchestrateAgents.value = saved.orchestrateAgents
if (saved.chatModelConfigId) chatModelConfigId.value = saved.chatModelConfigId
if (saved.chatTemperature != null) chatTemperature.value = saved.chatTemperature
if (saved.chatMaxIterations != null) chatMaxIterations.value = saved.chatMaxIterations
if (saved.chatMaxTokens != null) chatMaxTokens.value = saved.chatMaxTokens
if (saved.chatVoice) chatVoice.value = saved.chatVoice === 'teacher1' ? 'xiaoxiao' : saved.chatVoice
for (const arr of Object.values(messages.value)) arr.forEach(m => { if (m.steps?.length) m._traceOpen = false })
}
if (route.params.id) { currentAgentId.value = route.params.id as string; await switchAgent() }
else if (saved?.currentAgentId) { currentAgentId.value = saved.currentAgentId; await switchAgent() }
nextTick(() => {
messagesRef.value?.addEventListener('scroll', handleScroll, { passive: true })
scrollToBottom()
})
// Ctrl+F for search
const handleKeydown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
e.preventDefault()
searchInputRef.value?.focus()
}
}
document.addEventListener('keydown', handleKeydown)
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
})
onUnmounted(() => { messagesRef.value?.removeEventListener('scroll', handleScroll) })
async function loadAgents() {
try { const resp = await api.get('/api/v1/agents'); agents.value = resp.data || [] } catch { /* ignore */ }
}
async function switchAgent() {
if (!currentAgentId.value) { agent.value = null; currentSessionId.value = ''; sessions.value = []; saveState(); nextTick(scrollToBottom); return }
try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data
await loadSessions()
await loadBranches()
saveState()
nextTick(scrollToBottom)
} catch { ElMessage.error('加载 Agent 失败'); agent.value = null }
}
async function loadSessions() {
if (!currentAgentId.value) return
sessionsLoading.value = true
try { const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions?limit=50`); sessions.value = resp.data.sessions || [] } catch { /* ignore */ }
finally { sessionsLoading.value = false }
}
async function switchSession(sid: string) {
if (sid === '__new__') { newSession(); return }
currentSessionId.value = sid
const key = currentAgentKey.value; sessionId.value[key] = sid
sessionsLoading.value = true
try {
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${sid}/messages?limit=200`)
const serverMessages = resp.data.messages || []
messages.value[key] = serverMessages.map((m: any) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content || '', timestamp: m.created_at ? new Date(m.created_at).getTime() : Date.now(),
tool_calls: m.tool_name ? [{ function: { name: m.tool_name, arguments: m.tool_input } }] : [],
}))
hasMoreHistory.value = resp.data.has_more || false
} catch { ElMessage.warning('加载对话历史失败') }
finally { sessionsLoading.value = false; saveState(); nextTick(scrollToBottom) }
}
function newSession() {
const key = currentAgentKey.value; messages.value[key] = []; sessionId.value[key] = ''
currentSessionId.value = ''; currentBranchId.value = '';
saveState()
nextTick(scrollToBottom)
}
function getPresetQuestions(): string[] {
const name = agent.value?.name || ''; const desc = agent.value?.description || ''
const combined = `${name} ${desc}`.toLowerCase()
if (combined.includes('代码') || combined.includes('编程') || combined.includes('开发'))
return ['帮我写一个 Python 脚本来处理 CSV 文件', '解释一下这段代码的作用和潜在问题', '如何设计一个 RESTful API 接口?']
if (combined.includes('写作') || combined.includes('文案') || combined.includes('文章'))
return ['帮我写一篇关于 AI 发展的文章大纲', '润色这段文字,让它更加流畅易读', '给我几个吸引眼球的标题方案']
if (combined.includes('分析') || combined.includes('数据') || combined.includes('报告'))
return ['分析这组数据的趋势和异常点', '帮我总结这份报告的核心要点', '对比 A 和 B 两个方案的优劣']
return ['介绍一下你的能力和可以使用的工具', '帮我分析一个复杂问题', '搜索并整理最新的相关信息']
}
function sendPresetQuestion(q: string) { inputMessage.value = q; nextTick(() => sendMessage()) }
function speakMessage(text: string) { if (!text) return; ttsSpeak(stripMarkdownForTTS(text), { voice: chatVoice.value }) }
function stopSpeak() { ttsStop() }
function startListening() { voiceStart('zh-CN') }
function stopListening() { voiceStop(); if (transcript.value) { inputMessage.value = (inputMessage.value + ' ' + transcript.value).trim(); transcript.value = '' } }
function relativeTimeText(isoStr?: string): string {
if (!isoStr) return ''
const diff = Date.now() - new Date(isoStr).getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return `${Math.floor(diff / 86400000)} 天前`
}
function onInputDragEnter(e: DragEvent) {
if (loading.value || (chatMode.value === 'single' && !currentAgentId.value)) return
e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
inputDragOver.value = true
}
async function onInputDrop(e: DragEvent) {
inputDragOver.value = false
if (chatMode.value === 'single' && !currentAgentId.value) { ElMessage.warning('请先选择智能体再上传文件'); return }
const files = e.dataTransfer?.files; if (files?.length) await uploadFiles(Array.from(files))
}
async function sendMessage() {
const text = inputMessage.value.trim()
const hasAttachments = pendingAttachments.value.length > 0
if (!text && !hasAttachments) return
if (loading.value) return
const attachCtx = buildAttachmentContext()
const fullText = text + attachCtx
const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = []
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now() })
inputMessage.value = ''; pendingAttachments.value = []; inputDragOver.value = false
loading.value = true; scrollToBottom()
try {
if (chatMode.value === 'orchestrate') {
const resp = await api.post('/api/v1/agent-chat/orchestrate', {
message: text, mode: orchestrateMode.value,
agents: orchestrateAgents.value.map(a => ({
id: a.id, name: a.name, system_prompt: a.system_prompt,
model: a.model, temperature: a.temperature, max_iterations: a.max_iterations,
tools: [], description: a.description,
})),
})
const data = resp.data as OrchestrateResult
data.steps.forEach(s => { s._open = false })
messages.value[key].push({
role: 'assistant', content: data.final_answer, timestamp: Date.now(),
orchestrateResult: data, _traceOpen: true,
})
} else {
const sessId = sessionId.value[key] || ''
const streamEndpoint = currentAgentId.value
? `/api/v1/agent-chat/${currentAgentId.value}/stream`
: '/api/v1/agent-chat/bare/stream'
let usedStreaming = false
let placeholderIdx = -1
streamingActive.value = false
abortController.value = new AbortController()
const streamTimeout = setTimeout(() => abortController.value?.abort(), 300000)
try {
const authToken = localStorage.getItem('access_token') || localStorage.getItem('token') || ''
const body: any = {
message: text,
session_id: sessId || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
}
if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
const resp = await fetch(streamEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}) },
body: JSON.stringify(body),
signal: abortController.value.signal,
})
if (resp.ok && resp.body) {
usedStreaming = true
const msg: ChatMessage = { role: 'assistant', content: '', timestamp: Date.now(), steps: [], _traceOpen: true, iterations: 0, tool_calls_made: 0 }
placeholderIdx = messages.value[key].push(msg) - 1
const currentMsg = messages.value[key][placeholderIdx]
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''; let receivedFirstEvent = false
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n'); buffer = parts.pop() || ''
for (const part of parts) {
const lines = part.split('\n'); let eventType = ''; let dataStr = ''
for (const line of lines) { if (line.startsWith('event: ')) eventType = line.slice(7); else if (line.startsWith('data: ')) dataStr = line.slice(6) }
if (!dataStr) continue
try {
const data = JSON.parse(dataStr)
if (!receivedFirstEvent) { receivedFirstEvent = true; streamingActive.value = true }
if (eventType === 'think') {
currentMsg.steps!.push({ iteration: data.iteration, type: 'think', content: data.content || '思考中...', reasoning: data.reasoning, tool_name: data.tool_names?.[0] })
} else if (eventType === 'tool_call') {
currentMsg.steps!.push({ iteration: data.iteration, type: 'tool_call', content: `调用工具: ${data.name}`, tool_name: data.name, tool_input: data.input })
} else if (eventType === 'tool_result') {
currentMsg.steps!.push({ iteration: data.iteration, type: 'tool_result', content: `工具 ${data.name} 返回结果`, tool_name: data.name, tool_result: data.result })
} else if (eventType === 'approval_required') {
approvalId.value = data.approval_id || ''; approvalToolName.value = data.tool_name || ''; approvalArgs.value = data.args || {}
showApprovalDialog.value = true
} else if (eventType === 'final') {
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''; currentMsg.iterations = data.iterations_used || 0; currentMsg.tool_calls_made = data.tool_calls_made || 0
if (data.session_id) { sessionId.value[key] = data.session_id; if (!currentSessionId.value) { currentSessionId.value = data.session_id; loadSessions() } }
streamingActive.value = false; loading.value = false
} else if (eventType === 'error') {
clearTimeout(streamTimeout); currentMsg.content = data.content || ''; currentMsg.status = 'error'
streamingActive.value = false; loading.value = false
}
} catch { /* skip */ }
}
}
clearTimeout(streamTimeout)
}
} catch { clearTimeout(streamTimeout); usedStreaming = false; streamingActive.value = false }
if (!usedStreaming) {
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) messages.value[key].splice(placeholderIdx, 1)
const fallbackEndpoint = currentAgentId.value ? `/api/v1/agent-chat/${currentAgentId.value}` : '/api/v1/agent-chat/bare'
const body: any = { message: text, session_id: sessId || undefined, temperature: chatTemperature.value, max_iterations: chatMaxIterations.value }
if (chatMaxTokens.value) body.max_tokens = chatMaxTokens.value
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
const resp = await api.post(fallbackEndpoint, body)
const data = resp.data
sessionId.value[key] = data.session_id; if (!currentSessionId.value) { currentSessionId.value = data.session_id; loadSessions() }
messages.value[key].push({
role: 'assistant', content: data.content, timestamp: Date.now(),
iterations: data.iterations_used, tool_calls_made: data.tool_calls_made,
status: data.truncated ? 'error' : 'success', steps: data.steps || [],
_traceOpen: data.steps && data.steps.length > 0,
})
}
}
saveState()
} catch (e: any) {
messages.value[key].push({
role: 'assistant', content: `错误:${e.response?.data?.detail || e.message || '请求失败'}`,
timestamp: Date.now(), status: 'error',
})
saveState()
} finally { loading.value = false; scrollToBottom() }
}
function toggleTrace(msg: ChatMessage) { msg._traceOpen = !msg._traceOpen }
function clearChat() {
const key = currentAgentKey.value; messages.value[key] = []
if (chatMode.value === 'single') { sessionId.value[key] = ''; currentSessionId.value = '' }
currentBranchId.value = ''
saveState(); nextTick(scrollToBottom)
}
function scrollToBottom() { nextTick(() => { if (messagesRef.value && isNearBottom.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
function stopGeneration() { abortController.value?.abort(); loading.value = false; streamingActive.value = false; saveState() }
function safeParseArgCount(args?: string): number { if (!args) return 0; try { return Object.keys(JSON.parse(args)).length } catch { return 0 } }
function editMessage(idx: number) {
const key = currentAgentKey.value; const msgs = messages.value[key]
if (!msgs || !msgs[idx] || msgs[idx].role !== 'user') return
inputMessage.value = msgs[idx].content; msgs.splice(idx, 1); saveState(); nextTick(scrollToBottom)
}
function deleteMessage(idx: number) {
const key = currentAgentKey.value; const msgs = messages.value[key]
if (!msgs || !msgs[idx]) return; msgs.splice(idx, 1); saveState(); nextTick(scrollToBottom)
}
function copyMessage(msg: ChatMessage) {
const text = msg.orchestrateResult?.final_answer || msg.content
if (!text) return
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制')).catch(() => ElMessage.warning('复制失败'))
}
function retryMessage(idx: number) {
const key = currentAgentKey.value; const msgs = messages.value[key]
if (!msgs) return
let userMsg = ''; let userIdx = -1
for (let i = idx - 1; i >= 0; i--) { if (msgs[i].role === 'user') { userMsg = msgs[i].content; userIdx = i; break } }
if (!userMsg) { ElMessage.warning('未找到可重试的消息'); return }
const removeIndices: number[] = [userIdx, idx]
for (let i = userIdx + 1; i < idx; i++) {
if (msgs[i].role === 'assistant' && (!msgs[i].content || msgs[i].content === '') && (msgs[i].steps?.length || 0) > 0) removeIndices.push(i)
}
removeIndices.sort((a, b) => b - a); for (const ri of removeIndices) msgs.splice(ri, 1)
saveState(); inputMessage.value = userMsg; nextTick(() => sendMessage())
}
// 分页加载更多历史
const hasMoreHistory = ref(false)
</script>
<style scoped>
.agent-chat-page { display: flex; flex-direction: column; height: 100%; max-width: 960px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-light); }
.page-header-left { display: flex; align-items: center; gap: 8px; }
.page-header-left h3 { margin: 0; font-size: 16px; font-weight: 600; }
.page-header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--el-text-color-secondary); gap: 12px; padding: 40px 20px; text-align: center; }
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); margin-top: 8px; }
.welcome-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.welcome-desc { margin: 0; font-size: 14px; color: var(--el-text-color-secondary); max-width: 480px; line-height: 1.6; }
.welcome-tags { display: flex; gap: 6px; }
.preset-questions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 520px; margin-top: 4px; }
.preset-q { padding: 8px 16px; background: var(--el-fill-color); border: 1px solid var(--el-border-color-light); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.15s; color: var(--el-text-color-regular); }
.preset-q:hover { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-5); color: var(--el-color-primary); }
.agent-quick-list { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; max-width: 480px; }
/* Chat settings popover */
.chat-settings-popover { padding: 4px 0; }
.message { display: flex; gap: 12px; max-width: 88%; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; }
.message-bubble { padding: 10px 14px; border-radius: 12px; background: var(--el-fill-color-light); line-height: 1.6; font-size: 14px; }
.message.user .message-bubble { background: var(--el-color-primary-light-8); }
.message.error .message-bubble { border: 1px solid var(--el-color-danger-light-5); }
.message-text :deep(pre) { background: #1e1e2e; color: #cdd6f4; padding: 14px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.55; margin: 8px 0; }
.message-text :deep(pre code) { background: none; padding: 0; border-radius: 0; font-size: inherit; color: inherit; }
.message-text :deep(code) { background: var(--el-fill-color-darker); padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; }
.message-text :deep(h1) { font-size: 1.4em; font-weight: 700; margin: 12px 0 6px; }
.message-text :deep(h2) { font-size: 1.2em; font-weight: 700; margin: 10px 0 4px; }
.message-text :deep(h3) { font-size: 1.1em; font-weight: 600; margin: 8px 0 4px; }
.message-text :deep(ul), .message-text :deep(ol) { padding-left: 20px; margin: 4px 0; }
.message-text :deep(li) { margin: 2px 0; }
.message-text :deep(blockquote) { border-left: 3px solid var(--el-color-primary); margin: 8px 0; padding: 4px 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 0 6px 6px 0; }
.message-text :deep(table) { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
.message-text :deep(th), .message-text :deep(td) { border: 1px solid var(--el-border-color); padding: 6px 10px; text-align: left; }
.message-text :deep(th) { background: var(--el-fill-color); font-weight: 600; }
.message-text :deep(a) { color: var(--el-color-primary); text-decoration: underline; }
.message-text :deep(hr) { border: none; border-top: 1px solid var(--el-border-color-light); margin: 12px 0; }
.message-text :deep(p) { margin: 4px 0; }
.message-text :deep(img) { max-width: 100%; border-radius: 8px; margin: 6px 0; }
/* Tool calls */
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-light); }
.tool-calls-header { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
.tool-call-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; }
.tool-name { font-weight: 500; color: var(--el-color-primary); }
/* Thinking trace */
.thinking-trace { margin-top: 10px; border-top: 1px solid var(--el-border-color-light); padding-top: 8px; }
.trace-header { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 12px; color: var(--el-color-primary); user-select: none; padding: 4px 0; }
.trace-steps { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.trace-step { display: flex; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--el-fill-color-lighter); border-left: 3px solid var(--el-border-color); }
.trace-step.step-think { border-left-color: var(--el-color-primary); }
.trace-step.step-tool_result { border-left-color: var(--el-color-warning); }
.trace-step.step-final { border-left-color: var(--el-color-success); }
.step-icon { flex-shrink: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--el-text-color-secondary); }
.step-body { flex: 1; min-width: 0; font-size: 13px; }
.step-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.step-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; font-weight: 500; }
.tag-think { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
.tag-tool_result { background: var(--el-color-warning-light-9); color: var(--el-color-warning); }
.tag-final { background: var(--el-color-success-light-9); color: var(--el-color-success); }
.step-iter { font-size: 11px; color: var(--el-text-color-placeholder); }
.step-tool-name { font-size: 11px; background: var(--el-color-info-light-9); color: var(--el-color-info); padding: 0 6px; border-radius: 4px; font-family: monospace; }
.step-content { line-height: 1.5; }
.step-content :deep(pre) { background: var(--el-fill-color); padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 4px 0; }
.step-reasoning, .step-tool-input, .step-tool-result { margin-top: 6px; }
.reasoning-header { font-size: 11px; color: var(--el-text-color-secondary); margin-bottom: 2px; font-weight: 500; }
.reasoning-text { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; font-style: italic; }
.step-tool-input pre, .step-tool-result pre { background: var(--el-fill-color-darker); padding: 6px 8px; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; margin: 0; }
/* Thinking dots */
.thinking { display: flex; gap: 4px; padding: 8px 0; }
.dot { width: 8px; height: 8px; background: var(--el-text-color-placeholder); border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; }
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
/* Session selector */
.session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
/* Orchestrate */
.orchestrate-result { font-size: 14px; }
.orch-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.orch-agent-count { font-size: 12px; color: var(--el-text-color-secondary); }
.orch-final { margin-bottom: 12px; }
.orch-section-title { font-size: 13px; font-weight: 600; color: var(--el-text-color-primary); margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--el-border-color-light); }
.orch-steps { display: flex; flex-direction: column; gap: 4px; }
.orch-step { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
.orch-step-header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; background: var(--el-fill-color-lighter); font-size: 13px; }
.orch-step-header:hover { background: var(--el-fill-color-light); }
.orch-step-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
.orch-step-body { padding: 8px 12px; font-size: 13px; background: var(--el-bg-color); }
.orch-editor { display: flex; flex-direction: column; gap: 12px; max-height: 500px; overflow-y: auto; }
.orch-agent-card { border: 1px solid var(--el-border-color-light); border-radius: 8px; padding: 12px; }
.orch-agent-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.orch-agent-num { font-weight: 600; color: var(--el-color-primary); font-size: 14px; }
.orch-agent-params { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
/* Search panel */
.search-panel { max-height: 420px; display: flex; flex-direction: column; }
.search-results { flex: 1; overflow-y: auto; margin-top: 10px; }
.search-count { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
.search-result-item { padding: 8px; border-radius: 6px; cursor: pointer; border: 1px solid var(--el-border-color-lighter); margin-bottom: 6px; transition: all 0.15s; }
.search-result-item:hover { background: var(--el-fill-color-light); border-color: var(--el-color-primary-light-5); }
.search-result-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.search-result-session { font-size: 11px; color: var(--el-text-color-placeholder); margin-left: auto; }
.search-result-time { font-size: 11px; color: var(--el-text-color-placeholder); }
.search-result-content { font-size: 13px; color: var(--el-text-color-regular); line-height: 1.4; max-height: 60px; overflow: hidden; }
.search-result-content :deep(.search-highlight) { background: #fde047; color: #1e293b; padding: 0 2px; border-radius: 2px; }
/* Branches panel */
.branches-panel { display: flex; flex-direction: column; gap: 12px; }
.branches-actions { margin-bottom: 4px; }
.branches-list { display: flex; flex-direction: column; gap: 8px; }
.branch-item { padding: 10px 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; cursor: pointer; transition: all 0.15s; }
.branch-item:hover { border-color: var(--el-color-primary-light-5); background: var(--el-fill-color-light); }
.branch-item.active { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.branch-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.branch-meta { display: flex; gap: 10px; font-size: 11px; color: var(--el-text-color-placeholder); }
.branch-actions { margin-top: 6px; display: flex; justify-content: flex-end; }
</style>