Files
aiagent/frontend/src/components/AgentChatPreview.vue
renjianbo 09467568ec feat: Agent 运行时、对话 API、作业助手与引擎修复及前端执行超时
- agent_runtime 模块与 agent_chat API,前端 AgentChat 视图与路由对接
- workflow_engine: code 节点命名空间与 json 引用修复
- llm_service: 工具调用 extra_body(如 DeepSeek)
- create_homework_manager_agent / _3 脚本与测试脚本扩展
- frontend: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS、AgentChatPreview/MainLayout 等
- 文档:架构说明与自主 Agent 改造完成情况

Made-with: Cursor
2026-05-01 11:31:48 +08:00

1634 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="agent-chat-preview">
<div class="chat-header">
<div class="agent-info">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<span class="agent-name">{{ agentName || 'Agent' }}</span>
</div>
<div class="header-actions">
<el-button text size="small" title="仅清空当前界面,刷新页面后会重新加载历史记录" @click="handleClearChat">
<el-icon><Delete /></el-icon>
清空对话
</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesContainer">
<!-- 开场白 -->
<div v-if="openingMessage" class="message agent-message">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(openingMessage)"></div>
</div>
</div>
<!-- 预设问题无历史且无对话时显示 -->
<div v-if="presetQuestions.length > 0 && messages.length === 0 && !historyLoading" class="preset-questions">
<div
v-for="(question, index) in presetQuestions"
:key="index"
class="preset-question"
@click="handlePresetQuestion(question)"
>
{{ question }}
</div>
</div>
<!-- 对话消息 -->
<div
v-for="(message, index) in messages"
:key="index"
:class="['message', message.role === 'user' ? 'user-message' : 'agent-message']"
>
<el-avatar
v-if="message.role === 'agent'"
:size="32"
:src="agentAvatar"
:icon="UserFilled"
/>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div
v-if="message.attachments?.length"
:class="['message-attachments', message.role === 'user' ? 'is-user' : 'is-agent']"
>
<div
v-for="(a, ai) in message.attachments"
:key="`msg-att-${index}-${ai}`"
class="message-attachment-item"
>
<img
v-if="a.isImage && a.thumbUrl"
:src="a.thumbUrl"
:alt="a.filename"
class="message-attachment-image"
@click="openImagePreview(a)"
/>
<div v-else class="message-attachment-file">
<el-icon :size="14"><Document /></el-icon>
<span class="message-attachment-name">{{ a.filename }}</span>
</div>
</div>
</div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
<el-avatar
v-if="message.role === 'user'"
:size="32"
:icon="UserFilled"
style="background-color: #409eff;"
/>
</div>
<!-- 加载中 -->
<div v-if="loading" class="message agent-message">
<el-avatar :size="32" :src="agentAvatar" :icon="UserFilled" />
<div class="message-content">
<div class="message-bubble loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在思考...</span>
</div>
</div>
</div>
<!-- 节点测试结果 -->
<div v-if="props.nodeTestResult" class="node-test-result">
<el-alert
:type="props.nodeTestResult.result.status === 'success' ? 'success' : 'error'"
:closable="true"
show-icon
@close="handleCloseNodeTest"
>
<template #title>
<div class="node-test-header">
<strong>节点测试: {{ props.nodeTestResult.node.data?.label || props.nodeTestResult.node.type }}</strong>
<span class="node-test-time">{{ props.nodeTestResult.result.execution_time }}ms</span>
</div>
</template>
<div class="node-test-content">
<div v-if="props.nodeTestResult.result.status === 'success'">
<div class="test-section">
<strong>输入:</strong>
<pre>{{ JSON.stringify(props.nodeTestResult.input, null, 2) }}</pre>
</div>
<div class="test-section">
<strong>输出:</strong>
<pre>{{ JSON.stringify(props.nodeTestResult.result.output, null, 2) }}</pre>
</div>
</div>
<div v-else>
<div class="test-section">
<strong>错误:</strong>
<pre>{{ props.nodeTestResult.result.error_message || '未知错误' }}</pre>
</div>
</div>
</div>
</el-alert>
</div>
</div>
<el-dialog
v-model="imagePreviewVisible"
title="图片预览"
width="min(86vw, 980px)"
append-to-body
destroy-on-close
>
<div class="image-preview-dialog-body">
<img
v-if="imagePreviewSrc"
:src="imagePreviewSrc"
:alt="imagePreviewTitle || '图片预览'"
class="image-preview-dialog-img"
/>
</div>
</el-dialog>
<div
class="chat-input-area"
:class="{ 'is-drag-over': inputDragOver }"
@dragenter.prevent="onInputDragEnter"
@dragleave.prevent="onInputDragLeave"
@dragover.prevent="onInputDragOver"
@drop.prevent="onInputDrop"
>
<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" :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"
:rows="2"
placeholder="发送消息…(可将图片拖入此区域)"
@keydown.enter.exact.prevent="handleSendMessage"
@keydown.enter.shift.exact="handleNewLine"
:disabled="loading || !agentId"
/>
<div class="input-footer">
<div class="disclaimer">
内容由AI生成无法确保真实准确仅供参考
</div>
<div class="input-actions">
<el-button text size="small" @click="handleVoiceInput">
<el-icon><Microphone /></el-icon>
</el-button>
<el-button
type="primary"
@click="handleSendMessage"
:disabled="(!inputMessage.trim() && pendingAttachments.length === 0) || loading || !agentId"
:loading="loading"
>
<el-icon><Promotion /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onActivated, onUnmounted } from 'vue'
import { ElMessage, ElNotification } from 'element-plus'
import {
UserFilled,
Delete,
Loading,
Paperclip,
Microphone,
Promotion,
Document
} from '@element-plus/icons-vue'
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
import { useUserStore } from '@/stores/user'
interface Message {
role: 'user' | 'agent'
content: string
timestamp: number
attachments?: MessageAttachment[]
}
interface PendingAttachment {
relative_path: string
filename: string
size?: number
content_type?: string
/** 图片:本地 blob URL用于缩略图移除或发送后 revoke */
thumbUrl?: string
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
previewText?: string
/** 非文本类:简短说明 */
previewNote?: string
}
interface MessageAttachment {
relative_path: string
filename: string
thumbUrl?: string
isImage?: boolean
content_type?: string
}
const props = defineProps<{
agentId?: string
agentName?: string
agentAvatar?: string
openingMessage?: string
presetQuestions?: string[]
nodeTestResult?: any
}>()
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)
/** 拖放图片到输入区时高亮 */
const inputDragOver = ref(false)
const imagePreviewVisible = ref(false)
const imagePreviewSrc = ref('')
const imagePreviewTitle = ref('')
let pollingInterval: any = null
let replyAdded = false // 标志位:防止重复添加回复
/** 设计器预览对话本地镜像(同浏览器同 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,
attachments: Array.isArray(m.attachments)
? m.attachments
.filter((a: any) => a && typeof a.relative_path === 'string' && typeof a.filename === 'string')
.map((a: any) => ({
relative_path: String(a.relative_path),
filename: String(a.filename),
isImage: !!a.isImage,
content_type: a.content_type ? String(a.content_type) : undefined
}))
: undefined
}))
} catch {
return []
}
}
function writeDesignChatToStorage(agentId: string, list: Message[]) {
try {
const slice = list.slice(-MAX_PERSIST_MESSAGES)
const compact = slice.map((m) => ({
role: m.role,
content: m.content,
timestamp: m.timestamp,
attachments: m.attachments?.map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
isImage: !!a.isImage,
content_type: a.content_type
}))
}))
let raw = JSON.stringify(compact)
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)
}
/** 本轮发送前已有对话(不含即将追加的当前用户句),供后端注入 LLM 上下文 */
function conversationHistoryPayloadBeforeNewUserTurn(): Array<{ role: string; content: string }> {
return messages.value.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: typeof m.content === 'string' ? m.content : String(m.content ?? '')
}))
}
/** 未登录时的浏览器会话 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 = newId()
localStorage.setItem(key, id)
}
return id
} catch {
// 勿用 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
await hydrateMessageAttachmentThumbs(messages.value)
nextTick(() => scrollToBottom())
}
const applyServerTurns = (
turns: Array<{
user_text: string
agent_text: string
created_at: string
execution_id: string
attachments?: Array<{
relative_path: string
filename: string
content_type?: string
}>
}>
) => {
const next: Message[] = []
for (const t of turns) {
const base = new Date(t.created_at).getTime()
const msgAttachments: MessageAttachment[] = (t.attachments || []).map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
content_type: a.content_type,
isImage:
!!a.content_type?.startsWith('image/') ||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename || a.relative_path)
}))
next.push({
role: 'user',
content: t.user_text || ' ',
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400,
attachments: msgAttachments.length ? msgAttachments : undefined
})
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
attachments?: Array<{
relative_path: string
filename: string
content_type?: 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)
revokeMessageAttachmentBlobs(messages.value)
messages.value = next
await hydrateMessageAttachmentThumbs(messages.value)
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 revokeMessageAttachmentBlobs(items: Message[]) {
for (const m of items) {
for (const a of m.attachments || []) {
if (a.thumbUrl) {
try {
URL.revokeObjectURL(a.thumbUrl)
} catch {
/* ignore */
}
}
}
}
}
function isThumbInMessages(url: string): boolean {
if (!url) return false
return messages.value.some((m) => (m.attachments || []).some((a) => a.thumbUrl === url))
}
async function fetchAttachmentThumb(relativePath: string): Promise<string | undefined> {
try {
const res = await api.get('/api/v1/uploads/preview/file', {
params: { file_path: relativePath },
responseType: 'blob',
skipErrorHandler: true
})
return URL.createObjectURL(res.data as Blob)
} catch {
return undefined
}
}
async function hydrateMessageAttachmentThumbs(target: Message[]) {
for (const m of target) {
for (const a of m.attachments || []) {
if (!a.isImage || a.thumbUrl || !a.relative_path) continue
const url = await fetchAttachmentThumb(a.relative_path)
if (url) a.thumbUrl = url
}
}
}
function openImagePreview(a: MessageAttachment) {
if (!a.thumbUrl) return
imagePreviewSrc.value = a.thumbUrl
imagePreviewTitle.value = a.filename
imagePreviewVisible.value = true
}
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 uploadPreviewFiles(fileList: File[]) {
if (!fileList.length) return
if (!props.agentId) {
ElMessage.warning('请先选中要预览的智能体后再上传附件')
return
}
const uploadedNames: string[] = []
for (const file of fileList) {
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,
content_type: (res.data as any)?.content_type,
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
})
}
}
async function onFileInputChange(ev: Event) {
const input = ev.target as HTMLInputElement
const files = input.files
input.value = ''
if (!files?.length) return
await uploadPreviewFiles(Array.from(files))
}
function onInputDragEnter(e: DragEvent) {
if (!props.agentId || loading.value) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
inputDragOver.value = true
}
function onInputDragLeave(e: DragEvent) {
e.preventDefault()
const el = e.currentTarget as HTMLElement
const rel = e.relatedTarget as Node | null
if (rel && el.contains(rel)) return
inputDragOver.value = false
}
function onInputDragOver(e: DragEvent) {
if (!props.agentId || loading.value) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
async function onInputDrop(e: DragEvent) {
e.preventDefault()
inputDragOver.value = false
if (!props.agentId || loading.value) {
if (!props.agentId) ElMessage.warning('请先选中要预览的智能体后再拖入图片')
return
}
const dt = e.dataTransfer
if (!dt?.files?.length) return
const list = Array.from(dt.files)
const images = list.filter((f) => isImageFile(f))
if (images.length === 0) {
ElMessage.warning('请拖入图片文件(如 png、jpg、jpeg、webp、gif 等)')
return
}
if (images.length < list.length) {
ElMessage.info('已忽略非图片文件,仅添加图片附件')
}
await uploadPreviewFiles(images)
}
// 发送消息
const handleSendMessage = async () => {
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
}
const conversation_history = conversationHistoryPayloadBeforeNewUserTurn()
// 添加用户消息
const userAttachments: MessageAttachment[] = attachSnap.map((a) => ({
relative_path: a.relative_path,
filename: a.filename,
thumbUrl: a.thumbUrl,
content_type: a.content_type,
isImage:
!!a.thumbUrl ||
Boolean(a.content_type?.startsWith('image/')) ||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename)
}))
messages.value.push({
role: 'user',
content: userBubble || '(附件)',
timestamp: Date.now(),
attachments: userAttachments.length ? userAttachments : undefined
})
// 滚动到底部
scrollToBottom()
// 发送到Agent
loading.value = true
try {
const response = await api.post(
'/api/v1/executions',
{
agent_id: props.agentId,
input_data: {
USER_INPUT: mergedForModel,
query: mergedForModel,
user_id: getPreviewContextUserId(props.agentId),
attachments: attachSnap,
conversation_history,
memory: { conversation_history }
}
},
{ timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS }
)
const execution = response.data
blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null
// 重置标志位
replyAdded = false
// 轮询执行状态
const checkStatus = async () => {
const finishBlobs = () => {
if (blobsPendingRevokeAfterRun.value) {
const revokable = blobsPendingRevokeAfterRun.value.filter(
(x) => !x.thumbUrl || !isThumbInMessages(x.thumbUrl)
)
revokeAttachmentBlobs(revokable)
blobsPendingRevokeAfterRun.value = null
}
}
try {
// 如果已经添加过回复,直接返回,避免重复添加
if (replyAdded) {
return
}
// 获取详细执行状态(包含节点执行信息)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
const status = statusResponse.data
// 将执行状态传递给父组件,用于显示工作流动画
emit('execution-status', status)
// 获取执行详情(用于提取输出结果)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
const exec = execResponse.data
if (exec.status === 'completed') {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
replyAdded = true
// 提取Agent回复
let agentReply = ''
if (exec.output_data) {
// 优先从 result 字段获取(工作流执行结果)
if (exec.output_data.result) {
// result 字段应该是纯文本字符串End节点的输出
if (typeof exec.output_data.result === 'string') {
agentReply = exec.output_data.result
} else {
agentReply = String(exec.output_data.result)
}
} else if (typeof exec.output_data === 'string') {
agentReply = exec.output_data
} else if (exec.output_data.output) {
agentReply = exec.output_data.output
} else if (exec.output_data.response) {
agentReply = exec.output_data.response
} else if (exec.output_data.text) {
agentReply = exec.output_data.text
} else {
agentReply = JSON.stringify(exec.output_data, null, 2)
}
}
messages.value.push({
role: 'agent',
content: agentReply || '执行完成',
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到最终的执行结果
setTimeout(() => {
emit('execution-status', null)
}, 3000) // 3秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
finishBlobs()
} else if (exec.status === 'failed') {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
replyAdded = true
messages.value.push({
role: 'agent',
content: `执行失败: ${exec.error_message || '未知错误'}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到失败节点的状态
setTimeout(() => {
emit('execution-status', null)
}, 5000) // 5秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
finishBlobs()
} else {
// 继续轮询pending 或 running 状态)
// 不需要做任何操作,等待下次轮询
}
} catch (error: any) {
// 防止重复添加:如果已经添加过回复,直接返回
if (replyAdded) {
return
}
// 标记已添加回复
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: `获取执行结果失败:${msg401}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
// 清除执行状态
emit('execution-status', null)
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
}
// 使用 setInterval 进行轮询每500毫秒检查一次更频繁能捕获快速执行的节点
pollingInterval = setInterval(checkStatus, 500)
// 立即执行一次
checkStatus()
} 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}`,
timestamp: Date.now()
})
loading.value = false
scrollToBottom()
ElMessage.error(error.response?.data?.detail || '发送失败')
}
}
// 预设问题点击
const handlePresetQuestion = (question: string) => {
inputMessage.value = question
handleSendMessage()
}
// 清空对话
const handleClearChat = () => {
revokeAttachmentBlobs([...pendingAttachments.value])
revokeMessageAttachmentBlobs(messages.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)
// 清除轮询
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
/**
* 知你等工作流约定「自然语言 + 末尾单行 JSONintent/reply/user_profile」。
* 聊天区若原样展示会重复;展示前去掉末尾 JSON 行(可连续多行);若整段只有 JSON 则用 reply 作为正文。
*/
const stripOneTrailingWorkflowJsonLine = (raw: string): string => {
if (!raw || typeof raw !== 'string') return ''
const t = raw.trimEnd()
const lastNl = t.lastIndexOf('\n')
const lastLine = (lastNl >= 0 ? t.slice(lastNl + 1) : t).trim()
if (!lastLine.startsWith('{')) return raw
try {
const j = JSON.parse(lastLine) as Record<string, unknown>
if (!j || typeof j !== 'object') return raw
const reply = j.reply
if (typeof reply !== 'string') return raw
const head = lastNl >= 0 ? t.slice(0, lastNl).trimEnd() : ''
if (head) return head
return reply
} catch {
return raw
}
}
const stripTrailingWorkflowJsonLine = (raw: string): string => {
let cur = raw
for (let i = 0; i < 6; i++) {
const next = stripOneTrailingWorkflowJsonLine(cur)
if (next === cur) break
cur = next
}
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 noProtocol = stripDsmlLines(content)
const display = stripTrailingWorkflowJsonLine(noProtocol)
return display.replace(/\n/g, '<br>')
}
// 格式化时间
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// 语音输入
const handleVoiceInput = () => {
ElMessage.info('语音输入功能开发中')
}
// 换行
const handleNewLine = () => {
// Shift+Enter 换行,不需要特殊处理
}
// 关闭节点测试结果
const handleCloseNodeTest = () => {
// 通过事件通知父组件清除测试结果
// 这里暂时不做处理,由父组件自动清除
}
// 监听消息变化,自动滚动 + 持久化到本机(设计器刷新可恢复)
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])
revokeMessageAttachmentBlobs(messages.value)
if (blobsPendingRevokeAfterRun.value) {
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
blobsPendingRevokeAfterRun.value = null
}
// 清除执行状态
emit('execution-status', null)
})
</script>
<style scoped>
.agent-chat-preview {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f5f5;
border-left: 1px solid #e4e7ed;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e4e7ed;
}
.agent-info {
display: flex;
align-items: center;
gap: 8px;
}
.agent-name {
font-weight: 500;
font-size: 14px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
align-items: flex-start;
}
.user-message {
flex-direction: row-reverse;
}
.message-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-message .message-content {
align-items: flex-end;
}
.message-bubble {
padding: 10px 14px;
border-radius: 12px;
max-width: 70%;
word-wrap: break-word;
line-height: 1.5;
}
.agent-message .message-bubble {
background: white;
color: #303133;
border: 1px solid #e4e7ed;
}
.user-message .message-bubble {
background: #409eff;
color: white;
}
.message-bubble.loading {
display: flex;
align-items: center;
gap: 8px;
color: #909399;
}
.message-time {
font-size: 12px;
color: #909399;
padding: 0 4px;
}
.message-attachments {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-width: min(70%, 420px);
gap: 8px;
margin-top: 6px;
padding-bottom: 2px;
}
.message-attachments.is-user {
justify-content: flex-end;
}
.message-attachments.is-agent {
justify-content: flex-start;
}
.message-attachment-item {
max-width: 220px;
flex: 0 0 auto;
}
.message-attachment-image {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e4e7ed;
background: #fff;
display: block;
cursor: zoom-in;
}
.message-attachment-file {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e4e7ed;
border-radius: 8px;
font-size: 12px;
background: #fff;
color: #606266;
max-width: 220px;
}
.message-attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-preview-dialog-body {
display: flex;
justify-content: center;
align-items: center;
min-height: 240px;
}
.image-preview-dialog-img {
max-width: 100%;
max-height: 72vh;
object-fit: contain;
border-radius: 10px;
border: 1px solid #ebeef5;
background: #fff;
}
.preset-questions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.preset-question {
padding: 10px 14px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: #409eff;
}
.preset-question:hover {
background: #ecf5ff;
border-color: #409eff;
}
.node-test-result {
margin-top: 16px;
margin-bottom: 16px;
}
.node-test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.node-test-time {
font-size: 12px;
color: #909399;
font-weight: normal;
}
.node-test-content {
margin-top: 8px;
}
.test-section {
margin-bottom: 12px;
}
.test-section strong {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #606266;
}
.test-section pre {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
max-height: 200px;
overflow: auto;
margin: 0;
border: 1px solid #e4e7ed;
}
.chat-input-area {
background: white;
border-top: 1px solid #e4e7ed;
padding: 12px;
border-radius: 0;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.chat-input-area.is-drag-over {
background: #ecf5ff;
box-shadow: inset 0 0 0 2px #409eff;
}
.input-toolbar {
margin-bottom: 8px;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.disclaimer {
font-size: 12px;
color: #909399;
}
.input-actions {
display: flex;
gap: 8px;
align-items: center;
}
:deep(.el-textarea__inner) {
border: 1px solid #dcdfe6;
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>