Files
Ars-Font/src/views/ChatView.vue
ROOG 86e0f4936d main: 增加工具调用支持及相关功能集成
- 添加多个工具卡片组件,包括 LsResultCard、BashResultCard、FileReadResultCard 和 ToolCallCard
- 更新 ChatView 消息处理逻辑,支持工具消息的解析与展示
- 实现工具调用与结果处理机制,完善工具消息的归一化及合并逻辑
- 优化消息界面,新增工具调用列表与对应的样式调整
- 更新工具调用相关功能的状态管理与交互逻辑
2025-12-24 01:43:01 +08:00

1835 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<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>