Files
aiagent/frontend/src/views/AgentChat.vue
renjianbo 8d9ddd3aa0 feat: image attachments via multimodal messages for vision models
- Backend: ChatRequest.attachments; image files read as base64 data URLs (8MB cap) and passed to run/run_stream as image_urls; last user message converted to multimodal parts only on the LLM-bound copy (history stays text); vision whitelist (kimi-k3, gpt-4o, claude, etc.), non-vision models keep text/OCR path
- Frontend: send attachments metadata with chat body (also fixes attachment context never being sent — body now uses fullText); user bubble shows image thumbnails
2026-07-26 23:48:29 +08:00

1415 lines
70 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 sortedSessions"
:key="s.session_id"
:label="s.title || '未命名对话'"
:value="s.session_id"
>
<div class="session-option-row">
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
<span class="session-option-title">
<el-icon v-if="s.is_pinned" :size="12" style="color:var(--el-color-warning);vertical-align:-1px"><Top /></el-icon>
{{ s.title || '未命名对话' }}
</span>
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
</div>
<span class="session-option-actions">
<el-icon
:size="14"
:style="{ color: s.is_pinned ? 'var(--el-color-warning)' : 'var(--el-text-color-placeholder)' }"
:title="s.is_pinned ? '取消置顶' : '置顶'"
@click.stop.prevent="togglePinSession(s)"
><Top /></el-icon>
<el-icon
:size="14"
style="color:var(--el-color-danger)"
title="删除会话"
@click.stop.prevent="deleteSession(s)"
><Delete /></el-icon>
</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" v-loading="presetLoading" element-loading-text="正在生成示例问题...">
<div class="preset-q" v-for="(q, qi) in presetQuestions" :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>
<template v-for="item in displayItems" :key="item.key">
<div v-if="item._divider" class="date-divider"><span class="date-divider-text">{{ item.label }}</span></div>
<ChatMessageBubble
v-else
:role="item.msg.role"
:content-html="renderMarkdown(item.msg.orchestrateResult?.final_answer || item.msg.content)"
:timestamp="item.msg.timestamp"
:status="item.msg.status"
:iterations="item.msg.iterations"
:tool-calls-made="item.msg.tool_calls_made"
:token-usage="item.msg.token_usage"
:attachments="item.msg.attachments"
>
<template v-if="item.msg.orchestrateResult" #extra>
<div class="orchestrate-result">
<div class="orch-header">
<el-tag size="small" type="info">{{ item.msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ item.msg.orchestrateResult.steps.length }} 个 Agent</span>
</div>
<div class="orch-steps">
<div v-for="(step, si) in item.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="!item.msg.orchestrateResult" #extra>
<div v-if="item.msg.tool_calls && item.msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header"><el-icon><Tools /></el-icon> 工具调用 ({{ item.msg.tool_calls.length }})</div>
<div v-for="(tc, j) in item.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="item.msg.steps && item.msg.steps.length > 0" class="thinking-trace">
<div class="trace-header" @click="toggleTrace(item.msg)">
<el-icon><CaretRight :style="{ transform: item.msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<span>思考链 ({{ item.msg.steps.length }} 步)</span>
</div>
<div v-show="item.msg._traceOpen" class="trace-steps">
<div v-for="(step, si) in item.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="item.msg.role === 'assistant' && item.msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(item.msg.orchestrateResult?.final_answer || item.msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
<el-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
</el-button>
<el-button v-if="item.msg.role === 'user'" link size="small" @click="editMessage(item.idx)" title="编辑"><el-icon><Edit /></el-icon></el-button>
<el-button v-if="item.msg.role === 'assistant'" link size="small" @click="copyMessage(item.msg)" title="复制"><el-icon><DocumentCopy /></el-icon></el-button>
<el-button link size="small" @click="createBranch(item.idx)" title="从此分支"><el-icon><Share /></el-icon></el-button>
<el-button v-if="item.msg.role === 'assistant' && !loading" link :type="item.msg.status === 'error' ? 'danger' : undefined" size="small" @click="retryMessage(item.idx)" :title="item.msg.status === 'error' ? '重试' : '重新生成'"><el-icon><Refresh /></el-icon></el-button>
<el-button link size="small" @click="deleteMessage(item.idx)" title="删除"><el-icon style="color:var(--el-color-danger)"><Delete /></el-icon></el-button>
</template>
</ChatMessageBubble>
</template>
<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, ElMessageBox } from 'element-plus'
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting, Search, Download, Share, MagicStick, Top } 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 type { MessageAttachment } 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[]
token_usage?: any; attachments?: MessageAttachment[]
_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] || [])
// 日期分割线:消息列表中跨天时插入分隔项
interface DisplayItem {
_divider?: boolean; label?: string; key: string
msg?: ChatMessage; idx?: number
}
function _dayStart(ts: number): number { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
function dayLabelText(ts: number): string {
const days = Math.round((_dayStart(Date.now()) - _dayStart(ts)) / 86400000)
if (days <= 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
return new Date(ts).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
const displayItems = computed<DisplayItem[]>(() => {
const items: DisplayItem[] = []
let lastDay = -1
displayMessages.value.forEach((msg, idx) => {
const day = _dayStart(msg.timestamp || Date.now())
if (day !== lastDay) {
lastDay = day
items.push({ _divider: true, label: dayLabelText(msg.timestamp || Date.now()), key: `divider-${day}` })
}
items.push({ key: `msg-${idx}-${msg.timestamp}`, msg, idx })
})
return items
})
// 编排模式
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 = []; presetQuestions.value = []; saveState(); nextTick(scrollToBottom); return }
try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data
await loadSessions()
await loadBranches()
loadPresetQuestions()
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)
}
// 置顶优先,其余按更新时间倒序
const sortedSessions = computed(() =>
[...sessions.value].sort((a, b) => {
if (!!a.is_pinned !== !!b.is_pinned) return a.is_pinned ? -1 : 1
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
})
)
async function togglePinSession(s: SessionInfo) {
try {
await api.patch(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${s.session_id}`, { is_pinned: !s.is_pinned })
s.is_pinned = !s.is_pinned
ElMessage.success(s.is_pinned ? '已置顶' : '已取消置顶')
} catch { ElMessage.error('操作失败') }
}
async function deleteSession(s: SessionInfo) {
try {
await ElMessageBox.confirm(
`确定删除会话「${s.title || '未命名对话'}」吗?该操作不可恢复。`,
'删除会话',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch { return }
try {
await api.delete(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${s.session_id}`)
sessions.value = sessions.value.filter(x => x.session_id !== s.session_id)
if (currentSessionId.value === s.session_id) newSession()
ElMessage.success('会话已删除')
} catch { ElMessage.error('删除失败') }
}
// ── 预设问题:LLM 按 Agent 描述生成,localStorage 缓存(Agent 更新后自动失效)──
const presetQuestions = ref<string[]>([])
const presetLoading = ref(false)
function fallbackPresetQuestions(): string[] {
const name = agent.value?.name || '这个 Agent'
return [
`作为${name},你能帮我解决什么问题?`,
'给我一个你能处理的具体例子',
'我们从哪里开始比较好?',
]
}
async function loadPresetQuestions() {
const a = agent.value
if (!a) { presetQuestions.value = []; return }
const cacheKey = `agent_preset_qs:${a.id}:${a.updated_at || ''}`
try {
const cached = localStorage.getItem(cacheKey)
if (cached) {
const qs = JSON.parse(cached)
if (Array.isArray(qs) && qs.length) { presetQuestions.value = qs; return }
}
} catch { /* ignore */ }
presetLoading.value = true
try {
const body: any = {
message: `你是「${a.name}」。简介:${a.description || '无'}。\n请生成 3 个用户最可能向你提问的示例问题(每个不超过 30 字)。只返回 JSON 数组,例如 ["问题1","问题2","问题3"],不要输出任何其他内容。`,
model: 'deepseek-v4-flash', max_iterations: 1, temperature: 0.7,
}
if (chatModelConfigId.value) body.model_config_id = chatModelConfigId.value
const resp = await api.post('/api/v1/agent-chat/bare', body)
const content = resp.data?.content || ''
const m = content.match(/\[[\s\S]*\]/)
const qs = m ? JSON.parse(m[0]) : []
const valid = Array.isArray(qs) ? qs.filter(q => typeof q === 'string' && q.trim()).map(q => q.trim()).slice(0, 3) : []
presetQuestions.value = valid.length ? valid : fallbackPresetQuestions()
if (valid.length) localStorage.setItem(cacheKey, JSON.stringify(valid))
} catch {
presetQuestions.value = fallbackPresetQuestions()
} finally { presetLoading.value = false }
}
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 sentAttachments = pendingAttachments.value.map(a => ({
relative_path: a.relative_path,
filename: a.filename,
content_type: a.content_type,
is_image: !!a.thumbUrl || (a.content_type || '').startsWith('image/'),
}))
const msgAttachments: MessageAttachment[] = pendingAttachments.value.map(a => ({
relative_path: a.relative_path,
filename: a.filename,
thumbUrl: a.thumbUrl,
isImage: !!a.thumbUrl || (a.content_type || '').startsWith('image/'),
content_type: a.content_type,
}))
const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = []
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now(), attachments: msgAttachments.length ? msgAttachments : undefined })
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: fullText, 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: fullText,
session_id: sessId || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
}
if (sentAttachments.length) body.attachments = sentAttachments
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.content = ''
currentMsg.steps!.push({ iteration: data.iteration, type: 'think', content: data.content || '思考中...', reasoning: data.reasoning, tool_name: data.tool_names?.[0] })
} else if (eventType === 'answer_chunk') {
currentMsg.content = (currentMsg.content || '') + (data.content || '')
scrollToBottom()
} 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.token_usage) currentMsg.token_usage = data.token_usage
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); streamingActive.value = false
if (abortController.value?.signal.aborted) {
// 用户主动停止:标记「已停止」,阻止非流式 fallback 重复请求
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) {
const m = messages.value[key][placeholderIdx]
if (m.content || (m.steps && m.steps.length)) { m.status = 'stopped' } else { messages.value[key].splice(placeholderIdx, 1) }
}
loading.value = false; saveState(); nextTick(scrollToBottom)
return
}
usedStreaming = 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: fullText, session_id: sessId || undefined, temperature: chatTemperature.value, max_iterations: chatMaxIterations.value }
if (sentAttachments.length) body.attachments = sentAttachments
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 }
async function clearChat() {
if (displayMessages.value.length === 0) return
try {
await ElMessageBox.confirm(
'清空后将开始新对话(历史会话仍保留在「对话记录」中)。',
'确认清空',
{ type: 'warning', confirmButtonText: '清空', cancelButtonText: '取消' }
)
} catch { return }
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-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.session-option-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.session-option-actions .el-icon { cursor: pointer; padding: 2px; }
.session-option-actions .el-icon:hover { transform: scale(1.15); }
.date-divider {
display: flex; align-items: center; justify-content: center;
margin: 16px 0 8px; width: 100%;
position: relative;
}
.date-divider::before {
content: ''; position: absolute; left: 0; right: 0; top: 50%;
border-top: 1px solid var(--el-border-color-lighter);
}
.date-divider-text {
position: relative; z-index: 1;
background: var(--el-bg-color);
padding: 2px 12px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.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>