feat: Agent 批量测试、作业助手与上传预览;Windows 启动脚本与文档- 新增 run_agent_test_cases 与示例 JSON、(红头)agent测试用例文档

- 扩展 test_agent_execution(--homework、UTF-8 控制台)
- 后端:uploads 预览、file_read、工作流与对话落盘等
- 前端:AgentChatPreview 与设计器相关调整
- 忽略 redis二进制、agent_workspaces、uploads、tessdata 等本机产物

Made-with: Cursor
This commit is contained in:
renjianbo
2026-04-13 20:17:18 +08:00
parent 0608161c82
commit df4fab1e6e
31 changed files with 3784 additions and 251 deletions

View File

@@ -64,9 +64,16 @@ api.interceptors.response.use(
return response
},
(error) => {
const skip = Boolean(
(error.config as { skipErrorHandler?: boolean } | undefined)?.skipErrorHandler
)
const response = error.response
const status = response?.status
const data = response?.data
if (skip) {
return Promise.reject(error)
}
// 处理401未授权
if (status === 401) {
@@ -82,9 +89,13 @@ api.interceptors.response.use(
return Promise.reject(error)
}
// 处理404未找到
// 处理404未找到FastAPI 常用 detail
if (status === 404) {
ElMessage.error(data?.message || '请求的资源不存在')
const msg =
(typeof data?.detail === 'string' ? data.detail : null) ||
data?.message ||
'请求的资源不存在'
ElMessage.error(msg)
return Promise.reject(error)
}
@@ -100,6 +111,16 @@ api.interceptors.response.use(
return Promise.reject(error)
}
// 413上传体积超限等
if (status === 413) {
const message =
(typeof data?.detail === 'string' ? data.detail : null) ||
data?.message ||
'请求体过大'
ElMessage.error(message)
return Promise.reject(error)
}
// 503多为 Redis/Celery 不可用FastAPI HTTPException 使用 detail
if (status === 503) {
const message =
@@ -127,8 +148,12 @@ api.interceptors.response.use(
return Promise.reject(error)
}
// 其他错误
const message = data?.message || error.message || '请求失败'
// 其他错误(含 FastAPI 常用 string detail
const message =
(typeof data?.detail === 'string' ? data.detail : null) ||
data?.message ||
error.message ||
'请求失败'
ElMessage.error(message)
return Promise.reject(error)
}

View File

@@ -6,7 +6,7 @@
<span class="agent-name">{{ agentName || 'Agent' }}</span>
</div>
<div class="header-actions">
<el-button text size="small" @click="handleClearChat">
<el-button text size="small" title="仅清空当前界面,刷新页面后会重新加载历史记录" @click="handleClearChat">
<el-icon><Delete /></el-icon>
清空对话
</el-button>
@@ -22,8 +22,8 @@
</div>
</div>
<!-- 预设问题 -->
<div v-if="presetQuestions.length > 0 && messages.length === 0" class="preset-questions">
<!-- 预设问题无历史且无对话时显示 -->
<div v-if="presetQuestions.length > 0 && messages.length === 0 && !historyLoading" class="preset-questions">
<div
v-for="(question, index) in presetQuestions"
:key="index"
@@ -106,11 +106,56 @@
</div>
<div class="chat-input-area">
<input
ref="fileInputRef"
type="file"
class="hidden-file-input"
multiple
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.json,.py,.ts,.js,.vue,.html,.xml,.zip,.png,.jpg,.jpeg,.gif,.webp,.bmp,.tif,.tiff"
@change="onFileInputChange"
/>
<div class="input-toolbar">
<el-button text size="small" @click="handleAttachFile">
<el-button text size="small" :disabled="loading || !agentId" @click="handleAttachFile">
<el-icon><Paperclip /></el-icon>
</el-button>
</div>
<div v-if="pendingAttachments.length > 0" class="attachment-thumb-strip">
<div
v-for="(a, i) in pendingAttachments"
:key="`th-${a.relative_path}-${i}`"
class="attachment-thumb-item"
>
<button
type="button"
class="attachment-thumb-remove"
title="移除"
@click="removeAttachment(i)"
>
×
</button>
<div v-if="a.thumbUrl" class="attachment-thumb-img-wrap">
<img :src="a.thumbUrl" class="attachment-thumb-img" :alt="a.filename" />
</div>
<div v-else class="attachment-thumb-file-placeholder">
<el-icon :size="28"><Document /></el-icon>
</div>
<div class="attachment-thumb-caption" :title="a.filename">{{ a.filename }}</div>
</div>
</div>
<div v-if="pendingAttachments.some((x) => x.previewText || x.previewNote)" class="attachment-preview-stack">
<div
v-for="(a, i) in pendingAttachments"
v-show="a.previewText || a.previewNote"
:key="`pv-${a.relative_path}-${i}`"
class="attachment-preview-card"
>
<div class="attachment-preview-title">
{{ a.previewText ? '内容预览' : '已上传' }} · {{ a.filename }}
</div>
<pre v-if="a.previewText" class="attachment-preview-body">{{ a.previewText }}</pre>
<div v-else-if="a.previewNote" class="attachment-preview-note">{{ a.previewNote }}</div>
</div>
</div>
<el-input
v-model="inputMessage"
type="textarea"
@@ -131,7 +176,7 @@
<el-button
type="primary"
@click="handleSendMessage"
:disabled="!inputMessage.trim() || loading || !agentId"
:disabled="(!inputMessage.trim() && pendingAttachments.length === 0) || loading || !agentId"
:loading="loading"
>
<el-icon><Promotion /></el-icon>
@@ -143,17 +188,19 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, watch, nextTick, onMounted, onActivated, onUnmounted } from 'vue'
import { ElMessage, ElNotification } from 'element-plus'
import {
UserFilled,
Delete,
Loading,
Paperclip,
Microphone,
Promotion
Promotion,
Document
} from '@element-plus/icons-vue'
import api from '@/api'
import { useUserStore } from '@/stores/user'
interface Message {
role: 'user' | 'agent'
@@ -161,6 +208,18 @@ interface Message {
timestamp: number
}
interface PendingAttachment {
relative_path: string
filename: string
size?: number
/** 图片:本地 blob URL用于缩略图移除或发送后 revoke */
thumbUrl?: string
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
previewText?: string
/** 非文本类:简短说明 */
previewNote?: string
}
const props = defineProps<{
agentId?: string
agentName?: string
@@ -174,42 +233,423 @@ const emit = defineEmits<{
'execution-status': [status: any]
}>()
const userStore = useUserStore()
const messages = ref<Message[]>([])
const inputMessage = ref('')
const loading = ref(false)
const historyLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<PendingAttachment[]>([])
/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl */
const blobsPendingRevokeAfterRun = ref<PendingAttachment[] | null>(null)
let pollingInterval: any = null
let replyAdded = false // 标志位:防止重复添加回复
/** 会话记忆需稳定 user_id见 agent记忆实现方案.md预览区按 Agent 维度持久化,对应 Cache 键 user_memory_* */
/** 设计器预览对话本地镜像(同浏览器同 Agent 在接口不可用或尚未落库时仍可恢复) */
const DESIGN_CHAT_STORAGE_PREFIX = 'agent_design_chat_v1'
const MAX_PERSIST_MESSAGES = 200
let persistTimer: ReturnType<typeof setTimeout> | null = null
let loadHistoryGeneration = 0
/** 本地缓存按「登录用户 + Agent」分桶跨设备以服务端为准时仍避免同机多账号串缓存 */
function designChatStorageKey(agentId: string) {
return `${DESIGN_CHAT_STORAGE_PREFIX}_${agentId}_${getPreviewContextUserId(agentId)}`
}
function readDesignChatFromStorage(agentId: string): Message[] {
try {
const raw = localStorage.getItem(designChatStorageKey(agentId))
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed
.filter(
(m: any) =>
m &&
(m.role === 'user' || m.role === 'agent') &&
typeof m.content === 'string' &&
typeof m.timestamp === 'number'
)
.map((m: any) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp
}))
} catch {
return []
}
}
function writeDesignChatToStorage(agentId: string, list: Message[]) {
try {
const slice = list.slice(-MAX_PERSIST_MESSAGES)
let raw = JSON.stringify(slice)
if (raw.length > 450_000) {
raw = JSON.stringify(slice.slice(-80))
}
localStorage.setItem(designChatStorageKey(agentId), raw)
} catch {
// 存储满或禁用
}
}
function schedulePersistDesignChat() {
if (!props.agentId) return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = null
if (props.agentId) writeDesignChatToStorage(props.agentId, messages.value)
}, 400)
}
function clearDesignChatStorage(agentId: string) {
try {
localStorage.removeItem(designChatStorageKey(agentId))
} catch {
/* ignore */
}
}
/**
* 预览/执行写入 input_data.user_id
* - 已登录:用账号 id换设备同一账号可拉同一套历史
* - 未登录:退回浏览器级 preview_ 会话(仅本机)
*/
function getPreviewContextUserId(agentId: string): string {
const uid = userStore.user?.id
if (uid) return String(uid)
return getPreviewSessionUserId(agentId)
}
/** 未登录时的浏览器会话 id见 agent记忆实现方案.md */
function getPreviewSessionUserId(agentId: string): string {
const key = `agent_preview_uid_${agentId}`
const newId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID
? `preview_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
: `preview_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
try {
let id = localStorage.getItem(key)
if (!id) {
id =
typeof crypto !== 'undefined' && crypto.randomUUID
? `preview_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
: `preview_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
id = newId()
localStorage.setItem(key, id)
}
return id
} catch {
return `preview_${agentId}_${Date.now()}`
// 勿用 Date.now() 作唯一后缀:每次刷新会换 ID历史接口永远对不上
try {
let id = sessionStorage.getItem(key)
if (!id) {
id = newId()
sessionStorage.setItem(key, id)
}
return id
} catch {
return `preview_${agentId}_browser`
}
}
}
/** 从服务端恢复本会话preview_user_id 与 input_data.user_id 一致)的已完成对话 */
async function loadChatHistory() {
if (!props.agentId) return
const gen = ++loadHistoryGeneration
const aid = props.agentId
historyLoading.value = true
// 先读本地镜像,刷新/再次进入页面时立刻有内容(不闪空)
const cached = readDesignChatFromStorage(aid)
if (cached.length > 0) {
messages.value = cached
nextTick(() => scrollToBottom())
}
const applyServerTurns = (
turns: Array<{
user_text: string
agent_text: string
created_at: string
execution_id: string
}>
) => {
const next: Message[] = []
for (const t of turns) {
const base = new Date(t.created_at).getTime()
next.push({
role: 'user',
content: t.user_text || ' ',
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400
})
next.push({
role: 'agent',
content: t.agent_text || '',
timestamp: Number.isFinite(base) ? base : Date.now()
})
}
return next
}
type HistTurn = {
user_text: string
agent_text: string
created_at: string
execution_id: string
}
const fetchHist = async (previewUserId: string | undefined): Promise<HistTurn[]> => {
const url = `/api/v1/agents/${aid}/preview-chat-history`
const params: Record<string, string | number> = { limit: 80 }
if (previewUserId) params.preview_user_id = previewUserId
const res = await api.get(url, { params, skipErrorHandler: true })
return Array.isArray(res.data) ? (res.data as HistTurn[]) : []
}
try {
const contextId = getPreviewContextUserId(aid)
const browserPreviewId = getPreviewSessionUserId(aid)
const byExe = new Map<string, HistTurn>()
const merge = (list: HistTurn[]) => {
for (const t of list) {
if (t?.execution_id) byExe.set(t.execution_id, t)
}
}
merge(await fetchHist(contextId))
// 登录前产生的记录多为 preview_xxx登录后 context 变为账号 id必须合并本机仍保存的 preview 会话,否则会只剩极少数条
if (userStore.user?.id && browserPreviewId !== contextId) {
merge(await fetchHist(browserPreviewId))
}
// 两次仍为空(例如新浏览器只有库里旧 preview、且本地无该 preview id再拉本 Agent 最近完成记录(设计页需 read 权限)
if (byExe.size === 0) {
merge(await fetchHist(undefined))
}
const turns = Array.from(byExe.values()).sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
if (gen !== loadHistoryGeneration) return
// 用户已在发消息/轮询中:不要用历史接口覆盖当前界面,避免吞掉刚发的内容
if (loading.value) {
return
}
if (Array.isArray(turns) && turns.length > 0) {
const next = applyServerTurns(turns)
messages.value = next
writeDesignChatToStorage(aid, next)
}
// 服务端无记录时保留上面已展示的本地镜像(若有)
} catch (e) {
console.warn('加载对话历史失败', e)
// 网络/404/401保留本地镜像
} finally {
if (gen === loadHistoryGeneration) {
historyLoading.value = false
nextTick(() => scrollToBottom())
}
}
}
function handleAttachFile() {
fileInputRef.value?.click()
}
function isImageFile(file: File): boolean {
if (file.type && file.type.startsWith('image/')) return true
const ext = file.name.split('.').pop()?.toLowerCase() || ''
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff'].includes(ext)
}
function revokeAttachmentBlobs(items: PendingAttachment[]) {
for (const a of items) {
if (a.thumbUrl) {
try {
URL.revokeObjectURL(a.thumbUrl)
} catch {
/* ignore */
}
}
}
}
function removeAttachment(index: number) {
const cur = pendingAttachments.value[index]
if (cur?.thumbUrl) {
try {
URL.revokeObjectURL(cur.thumbUrl)
} catch {
/* ignore */
}
}
pendingAttachments.value.splice(index, 1)
}
const PREVIEW_MAX_FILE_BYTES = 512 * 1024
const PREVIEW_MAX_CHARS = 12000
const TEXT_PREVIEW_EXTS = new Set([
'txt',
'md',
'markdown',
'csv',
'json',
'jsonl',
'log',
'yaml',
'yml',
'py',
'ts',
'js',
'mjs',
'cjs',
'vue',
'html',
'htm',
'xml',
'css',
'sql',
'sh',
'bat',
'ps1',
'ini',
'cfg',
'properties',
'rst',
'tex',
'gitignore',
'env'
])
function escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function readLocalTextPreview(file: File): Promise<string | null> {
const m = file.name.match(/\.([^.]+)$/)
const ext = (m?.[1] || '').toLowerCase()
if (!TEXT_PREVIEW_EXTS.has(ext)) return Promise.resolve(null)
if (file.size > PREVIEW_MAX_FILE_BYTES) return Promise.resolve(null)
return new Promise((resolve) => {
const r = new FileReader()
r.onload = () => {
let t = String(r.result ?? '')
if (t.length > PREVIEW_MAX_CHARS) {
t =
t.slice(0, PREVIEW_MAX_CHARS) +
`\n\n…仅展示前 ${PREVIEW_MAX_CHARS} 字,完整内容请发送后由助手读取文件)`
}
resolve(t)
}
r.onerror = () => resolve(null)
r.readAsText(file, 'UTF-8')
})
}
async function onFileInputChange(ev: Event) {
const input = ev.target as HTMLInputElement
const files = input.files
input.value = ''
if (!files?.length) return
if (!props.agentId) {
ElMessage.warning('请先选中要预览的智能体后再上传附件')
return
}
const uploadedNames: string[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
const fd = new FormData()
fd.append('file', file)
try {
const res = await api.post<PendingAttachment & { content_type?: string }>(
'/api/v1/uploads/preview',
fd
)
const d = res.data as PendingAttachment
if (d?.relative_path) {
const previewText = (await readLocalTextPreview(file)) || undefined
let thumbUrl: string | undefined
if (isImageFile(file)) {
thumbUrl = URL.createObjectURL(file)
}
const entry: PendingAttachment = {
relative_path: d.relative_path,
filename: d.filename || file.name,
size: d.size,
thumbUrl,
previewText,
previewNote: previewText
? undefined
: '发送后由智能体通过 file_read 读取(支持 PDF、Word、Excel、图片 OCR 等)'
}
pendingAttachments.value.push(entry)
uploadedNames.push(d.filename || file.name)
} else {
ElMessage.warning(`${file.name}」上传未返回有效路径,请重试`)
}
} catch {
/* 错误提示由 api 拦截器统一处理 */
}
}
if (uploadedNames.length === 1) {
ElNotification.success({
title: '附件上传成功',
message: `${uploadedNames[0]}」已加入待发送区,发送后智能体可通过 file_read 读取。`,
duration: 5000,
offset: 72
})
} else if (uploadedNames.length > 1) {
ElNotification.success({
title: '附件上传成功',
message: `${uploadedNames.length} 个文件已加入待发送区:${uploadedNames.join('、')}`,
duration: 5500,
offset: 72
})
}
}
// 发送消息
const handleSendMessage = async () => {
if (!inputMessage.value.trim() || loading.value || !props.agentId) return
if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId)
return
const userMessage = inputMessage.value.trim()
const attachSnap = pendingAttachments.value.slice()
pendingAttachments.value = []
inputMessage.value = ''
const attachHint =
attachSnap.length > 0
? `\n\n[用户上传的附件(相对工作区根路径,可用 file_read 读取):\n${attachSnap.map((a) => `- ${a.relative_path}${a.filename}`).join('\n')}]`
: ''
const mergedForModel = (userMessage || '(用户上传了附件,请阅读并回答。)') + attachHint
let userBubble = userMessage
if (attachSnap.length) {
const lines = attachSnap.map((a) => `📎 ${a.filename}`)
let head = userBubble ? `${userBubble}\n${lines.join('\n')}` : lines.join('\n')
const textPreviews = attachSnap
.filter((a) => a.previewText?.trim())
.map(
(a) =>
`\n—— ${a.filename} 原文预览 ——\n${escapeHtml(a.previewText!.trim())}`
)
if (textPreviews.length) {
head += `\n${textPreviews.join('\n')}`
}
userBubble = head
}
// 添加用户消息
messages.value.push({
role: 'user',
content: userMessage,
content: userBubble || '(附件)',
timestamp: Date.now()
})
@@ -222,19 +662,27 @@ const handleSendMessage = async () => {
const response = await api.post('/api/v1/executions', {
agent_id: props.agentId,
input_data: {
USER_INPUT: userMessage,
query: userMessage,
user_id: getPreviewSessionUserId(props.agentId)
USER_INPUT: mergedForModel,
query: mergedForModel,
user_id: getPreviewContextUserId(props.agentId),
attachments: attachSnap
}
})
const execution = response.data
blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null
// 重置标志位
replyAdded = false
// 轮询执行状态
const checkStatus = async () => {
const finishBlobs = () => {
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
blobsPendingRevokeAfterRun.value = null
}
}
try {
// 如果已经添加过回复,直接返回,避免重复添加
if (replyAdded) {
@@ -303,6 +751,7 @@ const handleSendMessage = async () => {
clearInterval(pollingInterval)
pollingInterval = null
}
finishBlobs()
} else if (exec.status === 'failed') {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
@@ -329,6 +778,7 @@ const handleSendMessage = async () => {
clearInterval(pollingInterval)
pollingInterval = null
}
finishBlobs()
} else {
// 继续轮询pending 或 running 状态)
// 不需要做任何操作,等待下次轮询
@@ -341,10 +791,16 @@ const handleSendMessage = async () => {
// 标记已添加回复
replyAdded = true
finishBlobs()
const st = error.response?.status
const msg401 =
st === 401
? '登录已过期或未登录401。请重新登录后再试预览对话。'
: error.response?.data?.detail || error.message
messages.value.push({
role: 'agent',
content: `获取执行结果失败: ${error.response?.data?.detail || error.message}`,
content: `获取执行结果失败${msg401}`,
timestamp: Date.now()
})
loading.value = false
@@ -367,6 +823,12 @@ const handleSendMessage = async () => {
} catch (error: any) {
console.error('发送消息失败:', error)
if (attachSnap.length) {
pendingAttachments.value = [...attachSnap, ...pendingAttachments.value]
}
inputMessage.value = userMessage
const last = messages.value[messages.value.length - 1]
if (last?.role === 'user') messages.value.pop()
messages.value.push({
role: 'agent',
content: `发送失败: ${error.response?.data?.detail || error.message}`,
@@ -386,7 +848,15 @@ const handlePresetQuestion = (question: string) => {
// 清空对话
const handleClearChat = () => {
revokeAttachmentBlobs([...pendingAttachments.value])
pendingAttachments.value = []
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
blobsPendingRevokeAfterRun.value = null
}
messages.value = []
if (props.agentId) clearDesignChatStorage(props.agentId)
ElMessage.info('已清空预览对话(含本机缓存);刷新后不再恢复本条记录')
// 清除执行状态
emit('execution-status', null)
// 清除轮询
@@ -438,10 +908,19 @@ const stripTrailingWorkflowJsonLine = (raw: string): string => {
return cur
}
const stripDsmlLines = (raw: string): string => {
if (!raw || typeof raw !== 'string') return ''
// 过滤协议泄露行:<DSML...> / <|DSML|...>
const lines = raw.split(/\r?\n/)
const filtered = lines.filter((line) => !/[<]\s*[|]DSML[|]/i.test(line))
return filtered.join('\n').trim()
}
// 格式化消息支持简单的Markdown
const formatMessage = (content: string) => {
if (!content) return ''
const display = stripTrailingWorkflowJsonLine(content)
const noProtocol = stripDsmlLines(content)
const display = stripTrailingWorkflowJsonLine(noProtocol)
return display.replace(/\n/g, '<br>')
}
@@ -451,10 +930,6 @@ const formatTime = (timestamp: number) => {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 附件
const handleAttachFile = () => {
ElMessage.info('文件上传功能开发中')
}
// 语音输入
const handleVoiceInput = () => {
@@ -472,17 +947,59 @@ const handleCloseNodeTest = () => {
// 这里暂时不做处理,由父组件自动清除
}
// 监听消息变化,自动滚动
watch(messages, () => {
scrollToBottom()
}, { deep: true })
// 监听消息变化,自动滚动 + 持久化到本机(设计器刷新可恢复)
watch(
messages,
() => {
scrollToBottom()
schedulePersistDesignChat()
},
{ deep: true }
)
watch(
() => props.agentId,
(id, prev) => {
if (id && id !== prev) {
loadChatHistory()
}
},
{ immediate: true }
)
// 登录/登出后 user_id 语义变化,需重拉历史与本机缓存键
watch(
() => userStore.user?.id,
(id, prev) => {
if (!props.agentId) return
if (id !== prev) loadChatHistory()
}
)
// 从 bfcache 返回时补拉历史(部分浏览器前进/后退不重建组件)
function onPageShow(ev: PageTransitionEvent) {
if (ev.persisted && props.agentId) {
loadChatHistory()
}
}
onMounted(() => window.addEventListener('pageshow', onPageShow as EventListener))
onActivated(() => {
if (props.agentId) loadChatHistory()
})
// 组件卸载时清理轮询
onUnmounted(() => {
window.removeEventListener('pageshow', onPageShow as EventListener)
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
revokeAttachmentBlobs([...pendingAttachments.value])
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
blobsPendingRevokeAfterRun.value = null
}
// 清除执行状态
emit('execution-status', null)
})
@@ -679,4 +1196,125 @@ onUnmounted(() => {
border-radius: 8px;
resize: none;
}
.hidden-file-input {
display: none;
}
.attachment-thumb-strip {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
min-height: 0;
}
.attachment-thumb-item {
position: relative;
width: 76px;
flex-shrink: 0;
text-align: center;
}
.attachment-thumb-remove {
position: absolute;
top: -6px;
right: -6px;
z-index: 2;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.attachment-thumb-remove:hover {
background: #f56c6c;
}
.attachment-thumb-img-wrap {
width: 72px;
height: 72px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e4e7ed;
background: #fafafa;
margin: 0 auto;
}
.attachment-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-thumb-file-placeholder {
width: 72px;
height: 72px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid #e4e7ed;
background: linear-gradient(145deg, #f5f7fa, #ebeef5);
color: #409eff;
}
.attachment-thumb-caption {
margin-top: 4px;
font-size: 11px;
color: #606266;
line-height: 1.2;
max-width: 76px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-preview-stack {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
max-height: 220px;
overflow-y: auto;
}
.attachment-preview-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fafafa;
padding: 8px 10px;
}
.attachment-preview-title {
font-size: 12px;
color: #606266;
margin-bottom: 6px;
font-weight: 600;
}
.attachment-preview-body {
margin: 0;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: #303133;
max-height: 160px;
overflow-y: auto;
}
.attachment-preview-note {
font-size: 12px;
color: #909399;
line-height: 1.45;
}
</style>

View File

@@ -33,6 +33,7 @@
<!-- 右侧预览与调试 -->
<div class="preview-panel">
<AgentChatPreview
:key="String(agentId || '')"
:agent-id="agentId"
:agent-name="currentAgent?.name"
:opening-message="openingMessage"