Files
aiagent/frontend/src/views/AgentChat.vue

1087 lines
47 KiB
Vue
Raw Normal View History

<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 模式:选择 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">
<span>{{ a.name }}</span>
</el-option>
</el-select>
<!-- Session selector -->
<el-select
v-model="currentSessionId"
placeholder="对话记录"
style="width: 200px"
: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">
<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>
</template>
<!-- 编排模式:模式选择 + Agent 数 -->
<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-button @click="clearChat" :disabled="displayMessages.length === 0">清空</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesRef">
<!-- Rich 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>
<!-- Messages using shared ChatMessageBubble -->
<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"
>
<!-- Orchestrate result -->
<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>
<!-- Tool calls + thinking trace for non-orchestrate messages -->
<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>
<!-- Actions slot -->
<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 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>
<!-- Shared ChatInputArea -->
<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="260" 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="chatModel" size="small" style="width:100%">
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
<el-option label="Claude Opus 4.6" value="claude-opus-4-6" />
<el-option label="Claude Sonnet 4.6" value="claude-sonnet-4-6" />
</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>
</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" size="small" style="width: 160px">
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
</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="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 } 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 { useMarkdown } from '@/composables/useMarkdown'
import { useFileUpload } from '@/composables/useFileUpload'
import { useTTS } from '@/composables/useTTS'
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
// Shared composables
const { renderMarkdown } = useMarkdown()
const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onDragLeave, onDrop, onFilesSelected, handleAttachFile } = useFileUpload()
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
const { isListening, transcript, supported: voiceSupported, start: voiceStart, stop: voiceStop } = useSpeechRecognition()
// TTS & Voice
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
temperature: number; max_iterations: number; description: string
}
const STORAGE_KEY = 'agent_chat_state'
interface ChatState {
messages: Record<string, ChatMessage[]>
sessionId: Record<string, string>
currentAgentId: string
chatMode: 'single' | 'orchestrate'
orchestrateMode: string
orchestrateAgents: OrchestrateAgentForm[]
}
async function resolveApproval(decision: string) {
try {
await api.post(`/api/v1/approval/${approvalId.value}/resolve`, { decision })
} catch {
// 审批已超时或已处理,忽略
}
showApprovalDialog.value = false
approvalId.value = ''
approvalToolName.value = ''
approvalArgs.value = {}
}
function saveState() {
try {
const state: ChatState = {
messages: messages.value,
sessionId: sessionId.value,
currentAgentId: currentAgentId.value,
chatMode: chatMode.value,
orchestrateMode: orchestrateMode.value,
orchestrateAgents: orchestrateAgents.value,
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
_savedAt: Date.now(),
} as any
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch { /* quota exceeded, ignore */ }
}
function loadState(): ChatState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) {
localStorage.removeItem(STORAGE_KEY)
return null
}
if (Array.isArray(parsed.messages)) {
const oldSessionId = parsed.sessionId || ''
parsed.messages = { '__bare__': parsed.messages }
parsed.sessionId = { '__bare__': oldSessionId }
}
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 chatModel = ref('deepseek-v4-pro')
const chatTemperature = ref(0.8)
const chatMaxIterations = ref(15)
const currentAgentKey = computed(() => {
if (chatMode.value === 'orchestrate') return '__orchestrate__'
return currentAgentId.value || '__bare__'
})
const displayMessages = computed(() => {
return 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', temperature: 0.7, max_iterations: 10, description: '' },
{ id: 'agent-b', name: 'Agent B', system_prompt: '你是一个专业的分析助手。', model: 'deepseek-v4-flash', 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',
temperature: 0.7,
max_iterations: 10,
description: '',
})
saveState()
}
// 模式/配置变化时自动保存
watch(chatMode, saveState)
watch(orchestrateMode, saveState)
function handleScroll() {
if (!messagesRef.value) return
const el = messagesRef.value
const threshold = 60
isNearBottom.value = (el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
onMounted(async () => {
await loadAgents()
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
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()
})
})
onUnmounted(() => {
messagesRef.value?.removeEventListener('scroll', handleScroll)
})
async function loadAgents() {
try { const resp = await api.get('/api/v1/agents'); agents.value = resp.data || [] }
catch (e) { console.error('加载 Agent 列表失败:', e) }
}
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()
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 { /* 静默失败 */ }
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 } }] : [],
}))
} catch {
ElMessage.warning('加载对话历史失败')
}
finally { sessionsLoading.value = false; saveState(); nextTick(scrollToBottom) }
}
function newSession() {
const key = currentAgentKey.value
messages.value[key] = []
sessionId.value[key] = ''
currentSessionId.value = ''
saveState()
nextTick(scrollToBottom)
}
function getPresetQuestions(): string[] {
const name = agent.value?.name || ''
const desc = agent.value?.description || ''
const combined = `${name} ${desc}`.toLowerCase()
const questions: string[] = []
if (combined.includes('代码') || combined.includes('编程') || combined.includes('开发')) {
questions.push('帮我写一个 Python 脚本来处理 CSV 文件', '解释一下这段代码的作用和潜在问题', '如何设计一个 RESTful API 接口?')
} else if (combined.includes('写作') || combined.includes('文案') || combined.includes('文章')) {
questions.push('帮我写一篇关于 AI 发展的文章大纲', '润色这段文字,让它更加流畅易读', '给我几个吸引眼球的标题方案')
} else if (combined.includes('分析') || combined.includes('数据') || combined.includes('报告')) {
questions.push('分析这组数据的趋势和异常点', '帮我总结这份报告的核心要点', '对比 A 和 B 两个方案的优劣')
} else {
questions.push(`介绍一下你的能力和可以使用的工具`, '帮我分析一个复杂问题', '搜索并整理最新的相关信息')
}
return questions
}
function sendPresetQuestion(q: string) {
inputMessage.value = q
nextTick(() => sendMessage())
}
// TTS
function speakMessage(text: string) {
if (!text) return
ttsSpeak(text)
}
function stopSpeak() {
ttsStop()
}
// Voice input
function startListening() {
voiceStart('zh-CN')
}
function stopListening() {
voiceStop()
if (transcript.value) {
inputMessage.value = (inputMessage.value + ' ' + transcript.value).trim()
transcript.value = ''
}
}
watch(transcript, (val) => {
if (val && isListening.value) { /* display during recording */ }
})
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)} 天前`
}
// onDragLeave handled by ChatInputArea + parent wiring
// The drag events from useFileUpload can be applied directly since ChatInputArea emits events
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 token = localStorage.getItem('token') || ''
const resp = await fetch(streamEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({
message: text,
session_id: sessId || undefined,
model: chatModel.value || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
}),
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') {
const thinkContent = data.content || '思考中...'
currentMsg.steps!.push({
iteration: data.iteration, type: 'think',
content: thinkContent,
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') {
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
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') {
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''
currentMsg.status = 'error'
streamingActive.value = false
loading.value = false
} else {
console.warn('[AgentChat] 未知 SSE 事件类型:', eventType, data)
}
} catch { /* 跳过畸形事件 */ }
}
}
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 resp = await api.post(fallbackEndpoint, {
message: text,
session_id: sessId || undefined,
model: chatModel.value || undefined,
temperature: chatTemperature.value,
max_iterations: chatMaxIterations.value,
})
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 = ''
}
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()
}
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
function safeParseArgCount(args?: string): number {
if (!args) return 0
try { return Object.keys(JSON.parse(args)).length }
catch { return 0 }
fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块 ## 安全修复 (12项) - Webhook接口添加全局Token认证,过滤敏感请求头 - 修复JWT Base64 padding公式,防止签名验证绕过 - 数据库密码/飞书Token从源码移除,改为环境变量 - 工作流引擎添加路径遍历防护 (_resolve_safe_path) - eval()添加模板长度上限检查 - 审批API添加认证依赖 - 前端v-html增强XSS转义,console.log仅开发模式输出 - 500错误不再暴露内部异常详情 ## Agent运行时修复 (7项) - 删除_inject_knowledge_context中未定义db变量的finally块 - 工具执行添加try/except保护,异常不崩溃Agent - LLM重试计入budget计数器 - self_review异常时passed=False - max_iterations截断标记success=False - 工具参数JSON解析失败时记录警告日志 - run()开始时重置_llm_invocations计数器 ## 配置与基础设施 - DEBUG默认False,SQL_ECHO独立配置项 - init_db()补全13个缺失模型导入 - 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项 - 新增.env.example模板文件 ## 前端修复 (12项) - 登录改用URLSearchParams替代FormData - 401拦截器通过Pinia store统一清理状态 - SSE流超时从60s延长至300s - final/error事件时清除streamTimeout - localStorage聊天记录添加24h TTL - safeParseArgCount替代模板中裸JSON.parse - fetchUser 401时同时清除user对象 ## 新增模块 - 知识进化: knowledge_extractor/retriever/tasks - 数字孪生: shadow_executor/comparison模型 - 行为采集: behavior_middleware/collector/fingerprint_engine - 代码审查: code_review_agent/document_review_agent - 反馈学习: feedback_learner - 瓶颈检测/优化引擎/成本估算/需求估算 - 速率限制器 (rate_limiter) - Alembic迁移 015-020 ## 文档 - 商业化落地计划 - 8篇docs文档 (架构/API/部署/开发/贡献等) - Docker Compose生产配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 19:50:20 +08:00
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
const d = new Date(ts)
return `${d.getMonth() + 1}月${d.getDate()}日 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
function editMessage(idx: number) {
const key = currentAgentKey.value
const msgs = messages.value[key]
if (!msgs || !msgs[idx]) return
const msg = msgs[idx]
if (msg.role !== 'user') return
inputMessage.value = msg.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++) {
const m = msgs[i]
if (m.role === 'assistant' && (!m.content || m.content === '') && (m.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())
}
</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 result */
.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); }
/* Orchestrate editor */
.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; }
</style>