- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard - 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示 - 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑 - 优化消息界面,新增工具调用列表与对应的样式调整 - 更新工具调用相关功能的状态管理与交互逻辑
1835 lines
48 KiB
Vue
1835 lines
48 KiB
Vue
<script setup>
|
||
import {
|
||
computed,
|
||
reactive,
|
||
ref,
|
||
watch,
|
||
nextTick,
|
||
onMounted,
|
||
onBeforeUnmount,
|
||
} from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import axios from 'axios'
|
||
import MarkdownIt from 'markdown-it'
|
||
import mermaid from 'mermaid'
|
||
import {
|
||
ToolCallCard,
|
||
LsResultCard,
|
||
FileReadResultCard,
|
||
BashResultCard,
|
||
} from '../components/tools'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
|
||
const inputText = ref('')
|
||
const uploadFiles = ref([])
|
||
const fileList = ref([])
|
||
const sending = ref(false)
|
||
const runStatus = ref('DONE')
|
||
const loadingMessages = ref(false)
|
||
const errorMessage = ref('')
|
||
const showJumpDown = ref(false)
|
||
const lastSeq = ref(0)
|
||
const eventSource = ref(null)
|
||
const sseRetryTimer = ref(null)
|
||
const archiveLoading = ref(false)
|
||
const sampleImage =
|
||
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="220" height="140" viewBox="0 0 220 140"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop stop-color="%23e0e9ff" offset="0%"/><stop stop-color="%23c7d8ff" offset="50%"/><stop stop-color="%23e8f1ff" offset="100%"/></linearGradient></defs><rect width="220" height="140" rx="12" fill="url(%23g)"/><text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="%231d4ed8" font-family="Arial" font-size="16">mock image</text></svg>'
|
||
const markdown = new MarkdownIt({
|
||
html: false,
|
||
linkify: true,
|
||
breaks: true,
|
||
typographer: true,
|
||
})
|
||
const defaultFence = markdown.renderer.rules.fence
|
||
markdown.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||
const token = tokens[idx]
|
||
const lang = (token.info || '').trim().toLowerCase()
|
||
if (lang === 'mermaid') {
|
||
const code = markdown.utils.escapeHtml(token.content || '')
|
||
return `<div class="mermaid">${code}</div>`
|
||
}
|
||
return defaultFence
|
||
? defaultFence(tokens, idx, options, env, self)
|
||
: self.renderToken(tokens, idx, options)
|
||
}
|
||
const defaultLinkOpen = markdown.renderer.rules.link_open
|
||
markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||
tokens[idx].attrSet('target', '_blank')
|
||
tokens[idx].attrSet('rel', 'noreferrer noopener')
|
||
return defaultLinkOpen
|
||
? defaultLinkOpen(tokens, idx, options, env, self)
|
||
: self.renderToken(tokens, idx, options)
|
||
}
|
||
const renderMarkdown = (text = '') => markdown.render(text || '')
|
||
let mermaidInitialized = false
|
||
const setupMermaid = () => {
|
||
if (mermaidInitialized || typeof window === 'undefined') return
|
||
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
|
||
mermaidInitialized = true
|
||
}
|
||
const renderMermaid = () => {
|
||
if (typeof window === 'undefined') return
|
||
const nodes = document.querySelectorAll('.bubble-text .mermaid:not([data-processed])')
|
||
if (!nodes.length) return
|
||
mermaid
|
||
.run({ nodes })
|
||
.catch(() => {
|
||
/* ignore render errors */
|
||
})
|
||
}
|
||
|
||
const sessionId = ref(
|
||
route.params.id
|
||
? String(route.params.id)
|
||
: localStorage.getItem('ars-current-session') || ''
|
||
)
|
||
|
||
const summary = reactive({
|
||
title: '未命名会话',
|
||
status: 'OPEN',
|
||
updatedAt: '--',
|
||
lastSeq: '--',
|
||
tags: [],
|
||
})
|
||
|
||
const messagesState = ref([])
|
||
const seenDedupeKeys = new Set()
|
||
const finalizedRunIds = new Set()
|
||
// Typewriter effect state for streaming deltas
|
||
const typingStates = new Map()
|
||
const typingIntervalMs = 24
|
||
// Tool calls grouped by run_id: run_id -> { toolCallId -> toolCallData }
|
||
const runToolCalls = new Map()
|
||
|
||
// 解析工具调用/结果的 content (JSON 字符串)
|
||
const parseToolContent = (content) => {
|
||
if (!content) return {}
|
||
try {
|
||
return JSON.parse(content)
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
// 获取 tool_call_id
|
||
const getToolCallId = (msg = {}) => msg.payload?.tool_call_id || null
|
||
|
||
// 获取父级 run_id(工具调用所属的主运行)
|
||
const getParentRunId = (msg = {}) => msg.payload?.parent_run_id || msg.payload?.run_id || null
|
||
|
||
// 判断是否为工具相关消息
|
||
const isToolMessage = (msg = {}) =>
|
||
msg.type === 'tool.call' || msg.type === 'tool.result'
|
||
|
||
// 判断是否为工具级别的 run.status(需要隐藏)
|
||
const isToolRunStatus = (msg = {}) =>
|
||
msg.type === 'run.status' && msg.payload?.tool_call_id
|
||
|
||
// 创建工具调用对象
|
||
const createToolCall = (msg) => {
|
||
const toolCallId = getToolCallId(msg)
|
||
const toolName = msg.payload?.name || 'unknown'
|
||
const isCall = msg.type === 'tool.call'
|
||
const isResult = msg.type === 'tool.result'
|
||
|
||
// 解析 content JSON
|
||
let parsedContent = {}
|
||
if (msg.content) {
|
||
try {
|
||
parsedContent = JSON.parse(msg.content)
|
||
} catch (e) {
|
||
console.warn('Failed to parse tool content:', msg.content, e)
|
||
}
|
||
}
|
||
|
||
const result = {
|
||
toolCallId,
|
||
toolName,
|
||
toolInput: isCall ? (msg.payload?.arguments || parsedContent) : null,
|
||
toolResult: isResult ? parsedContent : null,
|
||
toolStatus: isResult ? (msg.payload?.status || 'SUCCESS') : 'RUNNING',
|
||
toolError: msg.payload?.error || null,
|
||
truncated: msg.payload?.truncated || false,
|
||
seq: msg.seq ?? 0,
|
||
time: normalizeTime(msg.created_at),
|
||
}
|
||
// 调试日志
|
||
console.log('[createToolCall]', msg.type, toolName, result)
|
||
return result
|
||
}
|
||
|
||
// 合并工具调用(call + result)
|
||
const mergeToolCall = (existing, newData) => {
|
||
// 如果没有现有数据,直接返回新数据
|
||
if (!existing) return newData
|
||
// 如果没有新数据,直接返回现有数据
|
||
if (!newData) return existing
|
||
|
||
return {
|
||
...existing,
|
||
...newData, // newData 的字段优先,但保留 existing 中 newData 没有的字段
|
||
// 确保关键字段不丢失
|
||
toolCallId: newData.toolCallId || existing.toolCallId,
|
||
toolName: newData.toolName || existing.toolName,
|
||
toolInput: newData.toolInput !== null ? newData.toolInput : existing.toolInput,
|
||
toolResult: newData.toolResult !== null ? newData.toolResult : existing.toolResult,
|
||
toolStatus: newData.toolStatus || existing.toolStatus,
|
||
toolError: newData.toolError || existing.toolError,
|
||
truncated: newData.truncated || existing.truncated,
|
||
seq: Math.max(existing.seq || 0, newData.seq || 0),
|
||
time: newData.time || existing.time,
|
||
}
|
||
}
|
||
|
||
// 收集工具调用到 run
|
||
const collectToolCall = (msg) => {
|
||
const toolCallId = getToolCallId(msg)
|
||
const parentRunId = getParentRunId(msg)
|
||
console.log('[collectToolCall] type:', msg.type, 'toolCallId:', toolCallId, 'parentRunId:', parentRunId, 'msg:', msg)
|
||
if (!toolCallId || !parentRunId) return
|
||
|
||
if (!runToolCalls.has(parentRunId)) {
|
||
runToolCalls.set(parentRunId, new Map())
|
||
}
|
||
const toolsMap = runToolCalls.get(parentRunId)
|
||
const existing = toolsMap.get(toolCallId)
|
||
const newToolCall = createToolCall(msg)
|
||
toolsMap.set(toolCallId, mergeToolCall(existing, newToolCall))
|
||
}
|
||
|
||
// 获取某个 run 的所有工具调用(按 seq 排序)
|
||
const getToolCallsForRun = (runId) => {
|
||
const toolsMap = runToolCalls.get(runId)
|
||
if (!toolsMap) return []
|
||
const result = Array.from(toolsMap.values()).sort((a, b) => a.seq - b.seq)
|
||
console.log('[getToolCallsForRun] runId:', runId, 'tools:', result)
|
||
return result
|
||
}
|
||
const getRunId = (msg = {}) => msg.payload?.run_id || null
|
||
const shouldGroupByRun = (msg = {}) => {
|
||
const runId = getRunId(msg)
|
||
return Boolean(
|
||
runId &&
|
||
(msg.type === 'message.delta' ||
|
||
msg.type === 'agent.message' ||
|
||
msg.type === 'error')
|
||
)
|
||
}
|
||
const markDedupeKey = (msg = {}) => {
|
||
const key = msg.dedupe_key
|
||
if (!key) return false
|
||
if (seenDedupeKeys.has(key)) return true
|
||
seenDedupeKeys.add(key)
|
||
return false
|
||
}
|
||
const stopTyping = (messageId) => {
|
||
const state = typingStates.get(messageId)
|
||
if (state?.timer) {
|
||
clearInterval(state.timer)
|
||
}
|
||
typingStates.delete(messageId)
|
||
}
|
||
const stopAllTyping = () => {
|
||
Array.from(typingStates.keys()).forEach((messageId) => {
|
||
stopTyping(messageId)
|
||
})
|
||
}
|
||
const resetMessageCache = () => {
|
||
seenDedupeKeys.clear()
|
||
finalizedRunIds.clear()
|
||
runToolCalls.clear()
|
||
stopAllTyping()
|
||
}
|
||
const normalizeTime = (val) =>
|
||
val
|
||
? new Date(val).toLocaleTimeString('zh-CN', {
|
||
hour12: false,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
: '--:--'
|
||
|
||
const buildMessageId = (msg = {}, seq = 0, runId = null) =>
|
||
runId
|
||
? `run-${runId}`
|
||
: msg.dedupe_key || msg.message_id || `msg-${seq || Math.random()}`
|
||
|
||
const normalizeMessage = (msg = {}, options = {}) => {
|
||
const seq = msg.seq ?? lastSeq.value ?? 0
|
||
const runId =
|
||
options.runId ?? (options.groupByRun ? getRunId(msg) : null)
|
||
const statusText =
|
||
msg.type === 'error'
|
||
? msg.payload?.message ||
|
||
msg.payload?.error ||
|
||
msg.content ||
|
||
'系统错误'
|
||
: msg.content
|
||
const text =
|
||
options.text ?? statusText ?? (options.allowEmpty ? '' : '(空内容)')
|
||
const roleUpper = msg.role?.toUpperCase()
|
||
const normalizedRole =
|
||
roleUpper === 'USER' ? 'user' : roleUpper === 'SYSTEM' ? 'system' : 'agent'
|
||
const author =
|
||
roleUpper === 'USER' ? '你' : roleUpper === 'SYSTEM' ? '系统' : 'ARS'
|
||
// 获取该 run 的工具调用
|
||
const toolCalls = runId ? getToolCallsForRun(runId) : []
|
||
|
||
return {
|
||
id: options.id || buildMessageId(msg, seq, runId),
|
||
seq,
|
||
role: normalizedRole,
|
||
author,
|
||
time: normalizeTime(msg.created_at),
|
||
text,
|
||
attachments:
|
||
msg.payload?.attachments?.map((file) => ({
|
||
name: file.name || '附件',
|
||
type: file.type || 'file',
|
||
size: file.size || '',
|
||
url: file.url || sampleImage,
|
||
})) || [],
|
||
type: msg.type,
|
||
runId,
|
||
dedupeKey: msg.dedupe_key || null,
|
||
payload: msg.payload || null,
|
||
toolCalls, // 附加工具调用列表
|
||
}
|
||
}
|
||
|
||
const extractRunStatus = (msg = {}) => {
|
||
const status = msg.payload?.status || msg.content
|
||
return status === 'RUNNING' || status === 'DONE' || status === 'FAILED'
|
||
? status
|
||
: null
|
||
}
|
||
|
||
const applyRunStatus = (msg = {}) => {
|
||
if (msg.type !== 'run.status') return false
|
||
const status = extractRunStatus(msg)
|
||
if (!status) return false
|
||
runStatus.value = status
|
||
const runId = getRunId(msg)
|
||
if (status === 'FAILED' && runId) {
|
||
finalizedRunIds.add(runId)
|
||
stopTyping(`run-${runId}`)
|
||
}
|
||
if (typeof msg.seq === 'number') {
|
||
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq)
|
||
summary.lastSeq = lastSeq.value
|
||
}
|
||
return true
|
||
}
|
||
|
||
const refreshLastSeq = (list = []) => {
|
||
const maxSeq = list.reduce((max, item) => {
|
||
const seq = typeof item.seq === 'number' ? item.seq : 0
|
||
return seq > max ? seq : max
|
||
}, lastSeq.value || 0)
|
||
lastSeq.value = maxSeq
|
||
summary.lastSeq = maxSeq
|
||
}
|
||
|
||
const appendDeltaToList = (normalized, runIndexMap, msg) => {
|
||
const runId = getRunId(msg)
|
||
const deltaText = msg.content || ''
|
||
const index =
|
||
runId && runIndexMap.has(runId) ? runIndexMap.get(runId) : -1
|
||
if (index >= 0) {
|
||
const current = normalized[index]
|
||
const existingText = current.text === '(空内容)' ? '' : current.text
|
||
normalized[index] = {
|
||
...current,
|
||
text: `${existingText}${deltaText}`,
|
||
type: msg.type || current.type,
|
||
dedupeKey: msg.dedupe_key || current.dedupeKey,
|
||
seq: msg.seq ?? current.seq,
|
||
}
|
||
return
|
||
}
|
||
const base = normalizeMessage(msg, {
|
||
groupByRun: shouldGroupByRun(msg),
|
||
allowEmpty: true,
|
||
text: deltaText,
|
||
})
|
||
normalized.push(base)
|
||
if (base.runId) {
|
||
runIndexMap.set(base.runId, normalized.length - 1)
|
||
}
|
||
}
|
||
|
||
const normalizeMessageList = (list = []) => {
|
||
let latestStatus = runStatus.value
|
||
const normalized = []
|
||
const runIndexMap = new Map()
|
||
resetMessageCache()
|
||
|
||
// 第一遍:收集 finalizedRunIds 和所有工具调用
|
||
list.forEach((msg) => {
|
||
if (!msg) return
|
||
if (msg.type === 'agent.message') {
|
||
const runId = getRunId(msg)
|
||
if (runId) finalizedRunIds.add(runId)
|
||
}
|
||
if (msg.type === 'run.status') {
|
||
const status = extractRunStatus(msg)
|
||
if (status === 'FAILED') {
|
||
const runId = getRunId(msg)
|
||
if (runId) finalizedRunIds.add(runId)
|
||
}
|
||
}
|
||
// 收集工具调用
|
||
if (isToolMessage(msg)) {
|
||
collectToolCall(msg)
|
||
}
|
||
})
|
||
|
||
// 第二遍:构建消息列表
|
||
list.forEach((msg) => {
|
||
if (!msg) return
|
||
// 跳过工具消息(已收集到 runToolCalls)
|
||
if (isToolMessage(msg)) return
|
||
// 跳过工具级别的 run.status
|
||
if (isToolRunStatus(msg)) return
|
||
// 处理主运行的 run.status
|
||
if (msg.type === 'run.status') {
|
||
const status = extractRunStatus(msg)
|
||
if (status) latestStatus = status
|
||
return
|
||
}
|
||
if (markDedupeKey(msg)) return
|
||
|
||
const runId = getRunId(msg)
|
||
if (msg.type === 'message.delta') {
|
||
if (runId && finalizedRunIds.has(runId)) return
|
||
appendDeltaToList(normalized, runIndexMap, msg)
|
||
return
|
||
}
|
||
const normalizedMsg = normalizeMessage(msg, {
|
||
groupByRun: shouldGroupByRun(msg),
|
||
})
|
||
normalized.push(normalizedMsg)
|
||
if (normalizedMsg.runId) {
|
||
runIndexMap.set(normalizedMsg.runId, normalized.length - 1)
|
||
}
|
||
})
|
||
runStatus.value = latestStatus
|
||
refreshLastSeq(list)
|
||
return normalized
|
||
}
|
||
|
||
const upsertMessage = (msg) => {
|
||
const list = messagesState.value
|
||
const idx = list.findIndex(
|
||
(m) => m.id === msg.id || (msg.seq && m.seq === msg.seq)
|
||
)
|
||
if (idx >= 0) {
|
||
const next = [...list]
|
||
next[idx] = msg
|
||
messagesState.value = next
|
||
} else {
|
||
messagesState.value = [...list, msg]
|
||
}
|
||
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq || 0)
|
||
summary.lastSeq = lastSeq.value
|
||
summary.updatedAt =
|
||
msg.time ||
|
||
summary.updatedAt ||
|
||
new Date().toLocaleString('zh-CN', {
|
||
hour12: false,
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
const updateMessageText = (messageId, text) => {
|
||
const list = messagesState.value
|
||
const idx = list.findIndex((item) => item.id === messageId)
|
||
if (idx < 0) return
|
||
const next = [...list]
|
||
next[idx] = { ...next[idx], text }
|
||
messagesState.value = next
|
||
}
|
||
|
||
const startTyping = (messageId, targetText) => {
|
||
if (!messageId) return
|
||
if (typeof window === 'undefined') {
|
||
updateMessageText(messageId, targetText)
|
||
return
|
||
}
|
||
const state = typingStates.get(messageId) || { targetText: '', timer: null }
|
||
state.targetText = targetText
|
||
typingStates.set(messageId, state)
|
||
if (state.timer) return
|
||
state.timer = window.setInterval(() => {
|
||
const current = messagesState.value.find((item) => item.id === messageId)
|
||
if (!current) {
|
||
stopTyping(messageId)
|
||
return
|
||
}
|
||
const currentText = current.text || ''
|
||
if (state.targetText.length <= currentText.length) {
|
||
if (currentText !== state.targetText) {
|
||
updateMessageText(messageId, state.targetText)
|
||
}
|
||
stopTyping(messageId)
|
||
return
|
||
}
|
||
const remaining = state.targetText.length - currentText.length
|
||
const step = Math.max(1, Math.min(8, Math.ceil(remaining / 60)))
|
||
const nextText = state.targetText.slice(
|
||
0,
|
||
Math.min(state.targetText.length, currentText.length + step)
|
||
)
|
||
updateMessageText(messageId, nextText)
|
||
}, typingIntervalMs)
|
||
}
|
||
|
||
const appendDeltaMessage = (msg) => {
|
||
const runId = getRunId(msg)
|
||
if (runId && finalizedRunIds.has(runId)) return
|
||
const messageId = runId ? `run-${runId}` : buildMessageId(msg, msg.seq)
|
||
const existing =
|
||
runId && messagesState.value.length
|
||
? messagesState.value.find((item) => item.runId === runId)
|
||
: null
|
||
const existingText =
|
||
existing && existing.text && existing.text !== '(空内容)'
|
||
? existing.text
|
||
: ''
|
||
const pendingState = typingStates.get(messageId)
|
||
const baseText = pendingState?.targetText ?? existingText
|
||
const deltaText = msg.content || ''
|
||
const nextText = `${baseText}${deltaText}`
|
||
const normalized = normalizeMessage(msg, {
|
||
id: messageId,
|
||
groupByRun: shouldGroupByRun(msg),
|
||
allowEmpty: true,
|
||
text: existingText,
|
||
})
|
||
if (existing?.attachments?.length && !normalized.attachments?.length) {
|
||
normalized.attachments = existing.attachments
|
||
}
|
||
upsertMessage(normalized)
|
||
if (nextText !== existingText) {
|
||
startTyping(normalized.id, nextText)
|
||
}
|
||
}
|
||
|
||
// 处理工具消息的 SSE 更新
|
||
const handleToolMessage = (msg) => {
|
||
// 收集工具调用
|
||
collectToolCall(msg)
|
||
|
||
// 找到对应 run 的消息并更新其 toolCalls
|
||
const parentRunId = getParentRunId(msg)
|
||
if (!parentRunId) return
|
||
|
||
const list = messagesState.value
|
||
const existingIndex = list.findIndex(
|
||
(item) => item.runId === parentRunId
|
||
)
|
||
|
||
if (existingIndex >= 0) {
|
||
// 更新已存在消息的 toolCalls
|
||
const existing = list[existingIndex]
|
||
const next = [...list]
|
||
next[existingIndex] = {
|
||
...existing,
|
||
toolCalls: getToolCallsForRun(parentRunId),
|
||
}
|
||
messagesState.value = next
|
||
} else {
|
||
// 该 run 的 agent.message 还没到,创建临时占位消息
|
||
const placeholderMsg = {
|
||
id: `run-${parentRunId}`,
|
||
seq: msg.seq ?? 0,
|
||
role: 'agent',
|
||
author: 'ARS',
|
||
time: normalizeTime(msg.created_at),
|
||
text: '',
|
||
attachments: [],
|
||
type: 'agent.pending',
|
||
runId: parentRunId,
|
||
dedupeKey: null,
|
||
payload: null,
|
||
toolCalls: getToolCallsForRun(parentRunId),
|
||
}
|
||
messagesState.value = [...list, placeholderMsg]
|
||
}
|
||
}
|
||
|
||
const handleIncomingMessage = (msg) => {
|
||
if (!msg) return
|
||
// 忽略工具级别的 run.status
|
||
if (isToolRunStatus(msg)) return
|
||
if (applyRunStatus(msg)) return
|
||
if (markDedupeKey(msg)) return
|
||
|
||
// 处理工具消息
|
||
if (isToolMessage(msg)) {
|
||
handleToolMessage(msg)
|
||
return
|
||
}
|
||
|
||
if (msg.type === 'message.delta') {
|
||
appendDeltaMessage(msg)
|
||
return
|
||
}
|
||
if (msg.type === 'agent.message') {
|
||
const runId = getRunId(msg)
|
||
if (runId) {
|
||
finalizedRunIds.add(runId)
|
||
stopTyping(`run-${runId}`)
|
||
}
|
||
}
|
||
if (msg.type === 'error') {
|
||
const runId = getRunId(msg)
|
||
if (runId) finalizedRunIds.add(runId)
|
||
}
|
||
const normalized = normalizeMessage(msg, {
|
||
groupByRun: shouldGroupByRun(msg),
|
||
})
|
||
upsertMessage(normalized)
|
||
}
|
||
|
||
const normalizedSummary = (data = {}) => ({
|
||
title: data.session_name || '未命名会话',
|
||
status: data.status || 'OPEN',
|
||
updatedAt: data.updated_at?.slice(0, 16) || '--',
|
||
lastSeq: data.last_seq ?? '--',
|
||
tags: data.last_message_type ? [data.last_message_type] : [],
|
||
})
|
||
|
||
const runActive = computed(() => runStatus.value === 'RUNNING')
|
||
const inputLocked = computed(() => summary.status === 'CLOSED')
|
||
const messages = computed(() => messagesState.value || [])
|
||
|
||
const formatPayload = (payload) => {
|
||
try {
|
||
return JSON.stringify(payload ?? {}, null, 2)
|
||
} catch (e) {
|
||
return String(payload ?? '')
|
||
}
|
||
}
|
||
|
||
const togglePayload = (id) => {
|
||
const list = messagesState.value
|
||
const idx = list.findIndex((item) => item.id === id)
|
||
if (idx < 0) return
|
||
const next = [...list]
|
||
next[idx] = {
|
||
...next[idx],
|
||
showPayload: !next[idx].showPayload,
|
||
}
|
||
messagesState.value = next
|
||
}
|
||
|
||
const handleFileChange = (uploadFile, uploadFilesList) => {
|
||
fileList.value = uploadFilesList
|
||
uploadFiles.value = uploadFilesList.map((file) => ({
|
||
uid: file.uid,
|
||
name: file.name,
|
||
type: file.raw?.type?.startsWith('image/') ? 'image' : 'file',
|
||
size: file.size ? `${Math.ceil(file.size / 1024)}KB` : '',
|
||
url: file.url || (file.raw ? URL.createObjectURL(file.raw) : sampleImage),
|
||
}))
|
||
}
|
||
|
||
const handleFileRemove = (file, list) => {
|
||
const toAttachment = (item) => ({
|
||
uid: item.uid,
|
||
name: item.name,
|
||
type:
|
||
item.raw?.type?.startsWith('image/') || item.type?.startsWith('image/')
|
||
? 'image'
|
||
: 'file',
|
||
size: item.size ? `${Math.ceil(item.size / 1024)}KB` : '',
|
||
url: item.url || (item.raw ? URL.createObjectURL(item.raw) : sampleImage),
|
||
})
|
||
|
||
if (Array.isArray(list)) {
|
||
fileList.value = list
|
||
uploadFiles.value = list.map(toAttachment)
|
||
return
|
||
}
|
||
|
||
const filtered = fileList.value.filter((item) => item.uid !== file.uid)
|
||
fileList.value = filtered
|
||
uploadFiles.value = filtered.map(toAttachment)
|
||
}
|
||
|
||
const handleComposerKeydown = (event) => {
|
||
if (event.key === 'Enter' && event.ctrlKey) {
|
||
event.preventDefault()
|
||
handleSend()
|
||
}
|
||
}
|
||
|
||
const handleSend = () => {
|
||
if (sending.value) return
|
||
if (runActive.value) {
|
||
ElMessage.info('Agent 正在处理中,请稍候再试')
|
||
return
|
||
}
|
||
if (summary.status === 'CLOSED') {
|
||
ElMessage.warning('会话已归档,无法发送')
|
||
return
|
||
}
|
||
if (!sessionId.value) {
|
||
ElMessage.warning('请先在左侧选择或新建会话')
|
||
return
|
||
}
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
ElMessage.warning('请先登录')
|
||
router.replace({ name: 'login' })
|
||
return
|
||
}
|
||
if (!inputText.value.trim() && !uploadFiles.value.length) {
|
||
ElMessage.warning('请输入内容或选择附件')
|
||
return
|
||
}
|
||
|
||
const payload =
|
||
uploadFiles.value.length > 0
|
||
? {
|
||
attachments: uploadFiles.value.map((f) => ({
|
||
name: f.name,
|
||
type: f.type,
|
||
size: f.size,
|
||
url: f.url,
|
||
})),
|
||
}
|
||
: null
|
||
|
||
const body = {
|
||
role: 'USER',
|
||
type: 'user.prompt',
|
||
content: inputText.value.trim(),
|
||
payload,
|
||
dedupe_key: `ui-${Date.now()}`,
|
||
}
|
||
|
||
sending.value = true
|
||
axios
|
||
.post(`${apiBase}/sessions/${sessionId.value}/messages`, body, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
.then((resp) => {
|
||
const data = resp.data?.data || resp.data || {}
|
||
const now = new Date()
|
||
const nextMsg = normalizeMessage({
|
||
...data,
|
||
role: 'USER',
|
||
content: inputText.value.trim(),
|
||
payload:
|
||
uploadFiles.value.length > 0
|
||
? { attachments: uploadFiles.value }
|
||
: null,
|
||
created_at:
|
||
data.created_at ||
|
||
now.toISOString().slice(0, 19).replace('T', ' '),
|
||
})
|
||
upsertMessage(nextMsg)
|
||
inputText.value = ''
|
||
uploadFiles.value = []
|
||
fileList.value = []
|
||
nextTick(() => scrollToBottom())
|
||
ElMessage.success('已发送')
|
||
})
|
||
.catch((err) => {
|
||
const message =
|
||
err.response?.data?.message ||
|
||
err.message ||
|
||
'发送失败,请稍后再试'
|
||
ElMessage.error(message)
|
||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||
})
|
||
.finally(() => {
|
||
sending.value = false
|
||
})
|
||
}
|
||
|
||
const handleArchive = async () => {
|
||
if (!sessionId.value) return
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
router.replace({ name: 'login' })
|
||
return
|
||
}
|
||
archiveLoading.value = true
|
||
try {
|
||
const { data } = await axios.post(
|
||
`${apiBase}/sessions/${sessionId.value}/archive`,
|
||
{},
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
)
|
||
const info = data.data || data
|
||
Object.assign(summary, normalizedSummary(info))
|
||
lastSeq.value = info.last_seq || lastSeq.value
|
||
ElMessage.success('会话已归档')
|
||
} catch (err) {
|
||
const message =
|
||
err.response?.data?.message || '归档失败,请稍后再试'
|
||
ElMessage.error(message)
|
||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||
} finally {
|
||
archiveLoading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchSummary = async () => {
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
router.replace({ name: 'login' })
|
||
return
|
||
}
|
||
if (!sessionId.value) return
|
||
try {
|
||
const { data } = await axios.get(`${apiBase}/sessions/${sessionId.value}`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
const info = data.data || data
|
||
Object.assign(summary, normalizedSummary(info))
|
||
if (info.last_seq) {
|
||
lastSeq.value = info.last_seq
|
||
}
|
||
} catch (err) {
|
||
// 后端可能不支持单条查询,容忍 404
|
||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||
}
|
||
}
|
||
|
||
const fetchMessages = async () => {
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
router.replace({ name: 'login' })
|
||
return
|
||
}
|
||
if (!sessionId.value) return
|
||
loadingMessages.value = true
|
||
errorMessage.value = ''
|
||
try {
|
||
const { data } = await axios.get(
|
||
`${apiBase}/sessions/${sessionId.value}/messages`,
|
||
{
|
||
params: { after_seq: 0, limit: 200 },
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
}
|
||
)
|
||
const list = data.data || []
|
||
messagesState.value = normalizeMessageList(list)
|
||
if (list.length) {
|
||
const last = list[list.length - 1]
|
||
lastSeq.value = last.seq || 0
|
||
Object.assign(summary, {
|
||
title: summary.title || '未命名会话',
|
||
status: last.status || summary.status,
|
||
lastSeq: last.seq ?? summary.lastSeq,
|
||
updatedAt:
|
||
last.created_at?.slice(0, 16) ||
|
||
summary.updatedAt ||
|
||
new Date().toLocaleString('zh-CN', {
|
||
hour12: false,
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
})
|
||
}
|
||
} catch (err) {
|
||
const message =
|
||
err.response?.data?.message || '获取消息失败,请稍后再试'
|
||
errorMessage.value = message
|
||
ElMessage.error(message)
|
||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||
} finally {
|
||
loadingMessages.value = false
|
||
}
|
||
}
|
||
|
||
const fetchSingleMessage = async (messageId) => {
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token || !sessionId.value || !messageId) return
|
||
try {
|
||
const { data } = await axios.get(
|
||
`${apiBase}/sessions/${sessionId.value}/messages/${messageId}`,
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
)
|
||
const detail = data.data || data
|
||
handleIncomingMessage(detail)
|
||
} catch (err) {
|
||
if (err.response?.status === 401) router.replace({ name: 'login' })
|
||
}
|
||
}
|
||
|
||
const scrollToBottom = () => {
|
||
window.scrollTo({
|
||
top:
|
||
document.documentElement.scrollHeight ||
|
||
document.body.scrollHeight ||
|
||
0,
|
||
behavior: 'smooth',
|
||
})
|
||
}
|
||
|
||
const handleWindowScroll = () => {
|
||
const doc = document.documentElement
|
||
const top = window.scrollY || doc.scrollTop || 0
|
||
const distance = doc.scrollHeight - top - window.innerHeight
|
||
showJumpDown.value = distance > 160
|
||
}
|
||
|
||
const stopSse = () => {
|
||
if (sseRetryTimer.value) {
|
||
clearTimeout(sseRetryTimer.value)
|
||
sseRetryTimer.value = null
|
||
}
|
||
if (eventSource.value) {
|
||
eventSource.value.close()
|
||
eventSource.value = null
|
||
}
|
||
}
|
||
|
||
const checkSseAuth = async () => {
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
router.replace({ name: 'login' })
|
||
return false
|
||
}
|
||
try {
|
||
await axios.get(`${apiBase}/me`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
return true
|
||
} catch (err) {
|
||
if (err.response?.status === 401) {
|
||
router.replace({ name: 'login' })
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
const startSse = () => {
|
||
if (typeof window === 'undefined' || typeof EventSource === 'undefined')
|
||
return
|
||
if (!sessionId.value) return
|
||
const token = localStorage.getItem('ars-token') || ''
|
||
if (!token) {
|
||
router.replace({ name: 'login' })
|
||
return
|
||
}
|
||
|
||
stopSse()
|
||
const url = new URL(`${apiBase}/sessions/${sessionId.value}/sse`)
|
||
url.searchParams.set('after_seq', lastSeq.value || 0)
|
||
url.searchParams.set('limit', 200)
|
||
url.searchParams.set('token', token)
|
||
|
||
const es = new EventSource(url.toString())
|
||
es.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data || '{}')
|
||
if (!data) return
|
||
if (!data.content && data.message_id && !data.type) {
|
||
fetchSingleMessage(data.message_id)
|
||
return
|
||
}
|
||
handleIncomingMessage(data)
|
||
nextTick(() => scrollToBottom())
|
||
} catch (e) {
|
||
// ignore parse error
|
||
}
|
||
}
|
||
es.onerror = () => {
|
||
stopSse()
|
||
checkSseAuth().then((authed) => {
|
||
if (!authed) return
|
||
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
|
||
})
|
||
}
|
||
eventSource.value = es
|
||
}
|
||
|
||
const applySession = () => {
|
||
if (!sessionId.value) return
|
||
stopSse()
|
||
resetMessageCache()
|
||
lastSeq.value = 0
|
||
runStatus.value = 'DONE'
|
||
localStorage.setItem('ars-current-session', sessionId.value)
|
||
fetchSummary()
|
||
fetchMessages().then(() => {
|
||
nextTick(() => {
|
||
scrollToBottom()
|
||
startSse()
|
||
})
|
||
})
|
||
}
|
||
|
||
watch(
|
||
() => route.params.id,
|
||
(val) => {
|
||
sessionId.value =
|
||
val !== undefined
|
||
? String(val)
|
||
: localStorage.getItem('ars-current-session') || ''
|
||
applySession()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
messages,
|
||
() => {
|
||
nextTick(() => {
|
||
setupMermaid()
|
||
renderMermaid()
|
||
})
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
onMounted(() => {
|
||
setupMermaid()
|
||
window.addEventListener('scroll', handleWindowScroll, { passive: true })
|
||
nextTick(() => {
|
||
handleWindowScroll()
|
||
renderMermaid()
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('scroll', handleWindowScroll)
|
||
stopAllTyping()
|
||
stopSse()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="chat-shell">
|
||
<section class="session-hero">
|
||
<div class="hero-left">
|
||
<p class="eyebrow">会话详情</p>
|
||
<h2>{{ summary.title || '未命名会话' }}</h2>
|
||
<div class="hero-meta">
|
||
<el-tag
|
||
:type="summary.status === 'OPEN' ? 'success' : summary.status === 'LOCKED' ? 'warning' : 'info'"
|
||
effect="plain"
|
||
size="small"
|
||
>
|
||
{{ summary.status || 'UNKNOWN' }}
|
||
</el-tag>
|
||
<span class="muted">最近更新 · {{ summary.updatedAt || '--' }}</span>
|
||
<span class="muted">last_seq · {{ summary.lastSeq ?? '--' }}</span>
|
||
</div>
|
||
<div class="chips">
|
||
<span v-for="tag in summary.tags || []" :key="tag" class="chip">
|
||
{{ tag }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="hero-right">
|
||
<div class="stat">
|
||
<p class="stat-label">历史消息</p>
|
||
<p class="stat-value">{{ messages.length }}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="stat-label">草稿字数</p>
|
||
<p class="stat-value">{{ inputText.length }}</p>
|
||
</div>
|
||
<el-button
|
||
size="small"
|
||
:loading="archiveLoading"
|
||
:disabled="summary.status === 'CLOSED'"
|
||
@click="handleArchive"
|
||
>
|
||
{{ summary.status === 'CLOSED' ? '已归档' : '归档会话' }}
|
||
</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chat-window">
|
||
<div class="history">
|
||
<el-skeleton v-if="loadingMessages" animated :rows="4" class="skeleton" />
|
||
<el-alert
|
||
v-else-if="errorMessage"
|
||
:title="errorMessage"
|
||
type="error"
|
||
show-icon
|
||
:closable="false"
|
||
class="history-error"
|
||
/>
|
||
<el-empty
|
||
v-else-if="!messages.length"
|
||
description="暂无消息"
|
||
:image-size="100"
|
||
/>
|
||
<template v-else>
|
||
<div
|
||
v-for="msg in messages"
|
||
:key="msg.id"
|
||
class="bubble"
|
||
:class="[
|
||
msg.role === 'user'
|
||
? 'bubble-user'
|
||
: msg.role === 'system'
|
||
? 'bubble-system'
|
||
: 'bubble-agent',
|
||
]"
|
||
>
|
||
<div class="bubble-head">
|
||
<span class="author">{{ msg.author }}</span>
|
||
<span class="time">{{ msg.time }}</span>
|
||
<template v-if="msg.type === 'error'">
|
||
<el-tag size="small" type="danger" effect="plain">
|
||
系统错误
|
||
</el-tag>
|
||
</template>
|
||
<template v-else-if="msg.type === 'agent.pending'">
|
||
<el-tag size="small" type="warning" effect="plain">
|
||
正在执行
|
||
</el-tag>
|
||
</template>
|
||
<template v-else>
|
||
<el-tag
|
||
v-if="msg.role === 'agent'"
|
||
size="small"
|
||
type="info"
|
||
effect="plain"
|
||
>
|
||
ARS 回复
|
||
</el-tag>
|
||
<el-tag
|
||
v-else-if="msg.role === 'system'"
|
||
size="small"
|
||
type="warning"
|
||
effect="plain"
|
||
>
|
||
系统
|
||
</el-tag>
|
||
<el-tag
|
||
v-else
|
||
size="small"
|
||
type="primary"
|
||
effect="plain"
|
||
>
|
||
提示词
|
||
</el-tag>
|
||
</template>
|
||
</div>
|
||
<template v-if="msg.type === 'error'">
|
||
<div class="error-card">
|
||
<p class="bubble-text error-title">
|
||
{{ msg.text || '系统错误' }}
|
||
</p>
|
||
<button type="button" class="payload-toggle" @click="togglePayload(msg.id)">
|
||
{{ msg.showPayload ? '收起详情' : '查看详情' }}
|
||
</button>
|
||
<pre v-if="msg.showPayload" class="payload-json">
|
||
{{ formatPayload(msg.payload) }}
|
||
</pre>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
class="bubble-text"
|
||
v-html="renderMarkdown(msg.text)"
|
||
/>
|
||
</template>
|
||
<!-- 工具调用列表 (放在 agent 气泡底部) -->
|
||
<div v-if="msg.toolCalls?.length" class="tool-calls-list">
|
||
<template v-for="(tool, idx) in msg.toolCalls" :key="tool.toolCallId">
|
||
<ToolCallCard
|
||
v-if="tool.toolName"
|
||
:tool-name="tool.toolName"
|
||
:tool-input="tool.toolInput"
|
||
:is-latest="tool.toolStatus === 'RUNNING' || (idx === msg.toolCalls.length - 1 && !msg.toolCalls.some(t => t.toolStatus === 'RUNNING'))"
|
||
>
|
||
<div v-if="tool.toolStatus === 'RUNNING'" class="tool-loading">
|
||
<el-icon class="is-loading"><i class="el-icon-loading" /></el-icon>
|
||
<span>正在执行...</span>
|
||
</div>
|
||
<div v-else-if="tool.toolError" class="tool-error">
|
||
<el-alert :title="tool.toolError" type="error" :closable="false" />
|
||
</div>
|
||
<template v-else-if="tool.toolResult">
|
||
<LsResultCard
|
||
v-if="tool.toolName === 'ls'"
|
||
:result="tool.toolResult"
|
||
/>
|
||
<FileReadResultCard
|
||
v-else-if="tool.toolName === 'file_read'"
|
||
:result="tool.toolResult"
|
||
/>
|
||
<BashResultCard
|
||
v-else-if="tool.toolName === 'bash'"
|
||
:result="tool.toolResult"
|
||
/>
|
||
<pre v-else class="tool-json">{{ JSON.stringify(tool.toolResult, null, 2) }}</pre>
|
||
</template>
|
||
</ToolCallCard>
|
||
</template>
|
||
</div>
|
||
<div v-if="msg.attachments?.length" class="attachment-list">
|
||
<div
|
||
v-for="file in msg.attachments"
|
||
:key="file.name"
|
||
class="attachment"
|
||
:class="file.type"
|
||
>
|
||
<div
|
||
v-if="file.type === 'image'"
|
||
class="attachment-thumb"
|
||
:style="{ backgroundImage: file.url ? `url(${file.url})` : undefined }"
|
||
/>
|
||
<div class="attachment-meta">
|
||
<span class="name">{{ file.name }}</span>
|
||
<span class="size">{{ file.size || '图像' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="composer-dock">
|
||
<button
|
||
v-if="showJumpDown"
|
||
type="button"
|
||
class="jump-down"
|
||
@click="scrollToBottom"
|
||
>
|
||
↓ 回到底部
|
||
</button>
|
||
<div class="composer">
|
||
<el-upload
|
||
class="upload"
|
||
multiple
|
||
accept="image/*,.pdf,.txt,.doc,.docx,.md"
|
||
:auto-upload="false"
|
||
:disabled="inputLocked"
|
||
:show-file-list="false"
|
||
:file-list="fileList"
|
||
:on-change="handleFileChange"
|
||
:on-remove="handleFileRemove"
|
||
>
|
||
<button type="button" class="add-btn" aria-label="上传文件">
|
||
+
|
||
</button>
|
||
</el-upload>
|
||
<el-input
|
||
v-model="inputText"
|
||
type="textarea"
|
||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||
class="composer-input"
|
||
:placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
|
||
:disabled="inputLocked"
|
||
@keydown="handleComposerKeydown"
|
||
/>
|
||
<div class="send-wrap">
|
||
<el-button
|
||
color="#1d4ed8"
|
||
class="send-btn"
|
||
:loading="sending || runActive"
|
||
:disabled="inputLocked || runActive"
|
||
@click="handleSend"
|
||
>
|
||
发送
|
||
</el-button>
|
||
<span class="send-hint">
|
||
<span class="keycap">Ctrl</span> +
|
||
<span class="keycap">Enter</span> 发送
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="uploadFiles.length" class="pending-attachments">
|
||
<p class="pending-title">待发送附件</p>
|
||
<div class="pending-list">
|
||
<div
|
||
v-for="file in uploadFiles"
|
||
:key="file.uid"
|
||
class="pending-chip"
|
||
>
|
||
<div
|
||
v-if="file.type === 'image'"
|
||
class="pending-thumb"
|
||
:style="{ backgroundImage: file.url ? `url(${file.url})` : undefined }"
|
||
/>
|
||
<div class="pending-meta">
|
||
<span class="name">{{ file.name }}</span>
|
||
<span class="size">{{ file.size || '文件' }}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="remove"
|
||
aria-label="移除附件"
|
||
@click="handleFileRemove(file)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.chat-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 80vh;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.session-hero {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
padding: 18px 20px;
|
||
border-radius: 16px;
|
||
background: var(--panel-hero-bg);
|
||
border: 1px solid var(--panel-hero-border);
|
||
box-shadow: var(--shadow-1);
|
||
}
|
||
|
||
.hero-left h2 {
|
||
margin: 6px 0;
|
||
color: var(--text-primary);
|
||
font-size: 24px;
|
||
}
|
||
|
||
.eyebrow {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.hero-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.muted {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.chip {
|
||
padding: 6px 10px;
|
||
border-radius: 10px;
|
||
background: var(--chip-bg);
|
||
color: var(--chip-text);
|
||
font-size: 12px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.hero-right {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 10px;
|
||
min-width: 260px;
|
||
}
|
||
|
||
.stat {
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-soft);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.stat-label {
|
||
margin: 0 0 4px;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.stat-value {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.chat-window {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-radius: 16px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-soft);
|
||
box-shadow: var(--shadow-1);
|
||
align-items: center;
|
||
}
|
||
|
||
.history {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
width: min(1120px, 100%);
|
||
padding: 0 16px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.skeleton {
|
||
padding: 12px 8px;
|
||
}
|
||
|
||
.history-error {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.composer-dock {
|
||
position: sticky;
|
||
bottom: 0;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 0;
|
||
background: linear-gradient(180deg, transparent, var(--overlay-weak));
|
||
}
|
||
|
||
.jump-down {
|
||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||
background: var(--surface-strong);
|
||
color: var(--accent);
|
||
border-radius: 999px;
|
||
padding: 6px 12px;
|
||
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.08);
|
||
cursor: pointer;
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||
}
|
||
|
||
.jump-down:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 14px 28px rgba(17, 24, 39, 0.12);
|
||
}
|
||
|
||
.bubble {
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--border-soft);
|
||
max-width: 1040px;
|
||
box-shadow: var(--shadow-2);
|
||
width: min(1040px, calc(100% - 24px));
|
||
align-self: center;
|
||
background: var(--surface-strong);
|
||
word-break: break-word;
|
||
position: relative;
|
||
}
|
||
|
||
.bubble-user {
|
||
border-color: var(--bubble-user-border);
|
||
background: var(--bubble-user-bg);
|
||
}
|
||
|
||
.bubble-agent {
|
||
border-color: var(--bubble-agent-border);
|
||
}
|
||
.bubble-system {
|
||
border-color: var(--bubble-system-border);
|
||
background: var(--bubble-system-bg);
|
||
}
|
||
|
||
/* 工具调用列表容器 */
|
||
.tool-calls-list {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tool-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
color: var(--text-muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.tool-loading .is-loading {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.tool-error {
|
||
padding: 8px;
|
||
}
|
||
|
||
.tool-json {
|
||
margin: 0;
|
||
padding: 12px;
|
||
background: var(--code-bg);
|
||
color: var(--code-text);
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
font-size: 13px;
|
||
overflow: auto;
|
||
max-height: 300px;
|
||
border-radius: 0 0 12px 12px;
|
||
}
|
||
|
||
.bubble-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.author {
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.time {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.bubble-text {
|
||
margin: 0 0 8px;
|
||
color: var(--text-primary);
|
||
line-height: 1.7;
|
||
font-size: 15px;
|
||
}
|
||
.bubble-text :deep(p) {
|
||
margin: 0 0 10px;
|
||
}
|
||
.bubble-text :deep(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
.bubble-text :deep(ul),
|
||
.bubble-text :deep(ol) {
|
||
padding-left: 20px;
|
||
margin: 0 0 12px;
|
||
}
|
||
.bubble-text :deep(code) {
|
||
background: var(--surface-subtle);
|
||
border-radius: 6px;
|
||
padding: 2px 6px;
|
||
font-size: 13px;
|
||
}
|
||
.bubble-text :deep(pre) {
|
||
margin: 10px 0;
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
background: var(--code-bg);
|
||
color: var(--code-text);
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
overflow: auto;
|
||
border: 1px solid var(--code-border);
|
||
}
|
||
.bubble-text :deep(table) {
|
||
width: 100%;
|
||
margin: 12px 0;
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
border: 1.5px solid var(--table-border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--surface-strong);
|
||
}
|
||
.bubble-text :deep(th),
|
||
.bubble-text :deep(td) {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--table-divider);
|
||
border-right: 1px solid var(--table-divider);
|
||
color: var(--text-primary);
|
||
text-align: left;
|
||
}
|
||
.bubble-text :deep(tr:last-child td) {
|
||
border-bottom: none;
|
||
}
|
||
.bubble-text :deep(th:last-child),
|
||
.bubble-text :deep(td:last-child) {
|
||
border-right: none;
|
||
}
|
||
.bubble-text :deep(th) {
|
||
background: linear-gradient(180deg, var(--surface), var(--surface-subtle));
|
||
font-weight: 700;
|
||
}
|
||
.bubble-text :deep(.mermaid) {
|
||
margin: 10px 0;
|
||
padding: 12px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, var(--mermaid-bg1), var(--mermaid-bg2));
|
||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
.error-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
background: var(--error-card-bg);
|
||
border: 1px solid var(--error-card-border);
|
||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||
}
|
||
.error-title {
|
||
color: #b91c1c;
|
||
font-weight: 700;
|
||
}
|
||
.payload-toggle {
|
||
align-self: flex-start;
|
||
padding: 6px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--error-card-border);
|
||
background: var(--surface-subtle);
|
||
color: #f87171;
|
||
cursor: pointer;
|
||
transition: all 0.12s ease;
|
||
font-weight: 600;
|
||
}
|
||
.payload-toggle:hover {
|
||
background: rgba(248, 113, 113, 0.12);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.payload-json {
|
||
margin: 0;
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
background: var(--code-bg);
|
||
color: var(--code-text);
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
font-size: 13px;
|
||
overflow: auto;
|
||
max-height: 240px;
|
||
white-space: pre;
|
||
}
|
||
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.attachment {
|
||
display: grid;
|
||
grid-template-columns: 52px 1fr;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
background: var(--attachment-bg);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.attachment.file {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.attachment-thumb {
|
||
width: 52px;
|
||
height: 52px;
|
||
border-radius: 12px;
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-image: linear-gradient(135deg, var(--mermaid-bg1), var(--mermaid-bg2));
|
||
border: 1px solid var(--border-soft);
|
||
}
|
||
|
||
.attachment-meta .name {
|
||
display: block;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.attachment-meta .size {
|
||
display: block;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.composer {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
background: var(--composer-bg);
|
||
border: 1px solid var(--border-soft);
|
||
width: min(1120px, 100%);
|
||
box-shadow: var(--shadow-1);
|
||
}
|
||
|
||
.add-btn {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border-soft);
|
||
background: var(--surface-strong);
|
||
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.08);
|
||
font-size: 24px;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
|
||
.add-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 12px 24px rgba(17, 24, 39, 0.12);
|
||
}
|
||
|
||
.composer-input :deep(.el-textarea__inner) {
|
||
background: var(--input-bg);
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border-soft);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
min-height: 110px;
|
||
}
|
||
|
||
.send-btn {
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
font-weight: 700;
|
||
padding: 0 18px;
|
||
}
|
||
|
||
.send-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 6px;
|
||
}
|
||
|
||
.send-hint {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.keycap {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2px 6px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-soft);
|
||
background: var(--surface-strong);
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pending-attachments {
|
||
padding: 10px 0 0;
|
||
border-top: 1px dashed var(--border-soft);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.pending-title {
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.pending-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.pending-chip {
|
||
display: grid;
|
||
grid-template-columns: 48px 1fr 16px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
background: var(--pending-bg);
|
||
border: 1px solid var(--pending-border);
|
||
}
|
||
|
||
.pending-thumb {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-image: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||
}
|
||
|
||
.pending-meta .name {
|
||
display: block;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.pending-meta .size {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: var(--text-subtle);
|
||
}
|
||
|
||
.remove {
|
||
cursor: pointer;
|
||
color: var(--text-primary);
|
||
font-size: 16px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.session-hero {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-window {
|
||
padding: 12px;
|
||
}
|
||
|
||
.composer {
|
||
grid-template-columns: 1fr;
|
||
width: 100%;
|
||
}
|
||
|
||
.add-btn {
|
||
width: 100%;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 100%;
|
||
}
|
||
|
||
.send-wrap {
|
||
width: 100%;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
</style>
|