2026-01-19 00:09:36 +08:00
|
|
|
|
<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">
|
2026-04-13 20:17:18 +08:00
|
|
|
|
<el-button text size="small" title="仅清空当前界面,刷新页面后会重新加载历史记录" @click="handleClearChat">
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
<!-- 预设问题(无历史且无对话时显示) -->
|
|
|
|
|
|
<div v-if="presetQuestions.length > 0 && messages.length === 0 && !historyLoading" class="preset-questions">
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<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>
|
2026-04-13 22:52:36 +08:00
|
|
|
|
<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>
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<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>
|
2026-04-13 22:52:36 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-04-13 20:17:18 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<div class="input-toolbar">
|
2026-04-13 20:17:18 +08:00
|
|
|
|
<el-button text size="small" :disabled="loading || !agentId" @click="handleAttachFile">
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<el-icon><Paperclip /></el-icon>
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
2026-04-13 20:17:18 +08:00
|
|
|
|
<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>
|
2026-01-19 00:09:36 +08:00
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="inputMessage"
|
|
|
|
|
|
type="textarea"
|
|
|
|
|
|
:rows="2"
|
2026-04-13 22:52:36 +08:00
|
|
|
|
placeholder="发送消息…(可将图片拖入此区域)"
|
2026-01-19 00:09:36 +08:00
|
|
|
|
@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"
|
2026-04-13 20:17:18 +08:00
|
|
|
|
:disabled="(!inputMessage.trim() && pendingAttachments.length === 0) || loading || !agentId"
|
2026-01-19 00:09:36 +08:00
|
|
|
|
:loading="loading"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-icon><Promotion /></el-icon>
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-04-13 20:17:18 +08:00
|
|
|
|
import { ref, watch, nextTick, onMounted, onActivated, onUnmounted } from 'vue'
|
|
|
|
|
|
import { ElMessage, ElNotification } from 'element-plus'
|
2026-01-19 00:09:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
UserFilled,
|
|
|
|
|
|
Delete,
|
|
|
|
|
|
Loading,
|
|
|
|
|
|
Paperclip,
|
|
|
|
|
|
Microphone,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
Promotion,
|
|
|
|
|
|
Document
|
2026-01-19 00:09:36 +08:00
|
|
|
|
} from '@element-plus/icons-vue'
|
2026-05-01 11:31:48 +08:00
|
|
|
|
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
|
2026-04-13 20:17:18 +08:00
|
|
|
|
import { useUserStore } from '@/stores/user'
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
interface Message {
|
|
|
|
|
|
role: 'user' | 'agent'
|
|
|
|
|
|
content: string
|
|
|
|
|
|
timestamp: number
|
2026-04-13 22:52:36 +08:00
|
|
|
|
attachments?: MessageAttachment[]
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
interface PendingAttachment {
|
|
|
|
|
|
relative_path: string
|
|
|
|
|
|
filename: string
|
|
|
|
|
|
size?: number
|
2026-04-13 22:52:36 +08:00
|
|
|
|
content_type?: string
|
2026-04-13 20:17:18 +08:00
|
|
|
|
/** 图片:本地 blob URL,用于缩略图(移除或发送后 revoke) */
|
|
|
|
|
|
thumbUrl?: string
|
|
|
|
|
|
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
|
|
|
|
|
|
previewText?: string
|
|
|
|
|
|
/** 非文本类:简短说明 */
|
|
|
|
|
|
previewNote?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
interface MessageAttachment {
|
|
|
|
|
|
relative_path: string
|
|
|
|
|
|
filename: string
|
|
|
|
|
|
thumbUrl?: string
|
|
|
|
|
|
isImage?: boolean
|
|
|
|
|
|
content_type?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
agentId?: string
|
|
|
|
|
|
agentName?: string
|
|
|
|
|
|
agentAvatar?: string
|
|
|
|
|
|
openingMessage?: string
|
|
|
|
|
|
presetQuestions?: string[]
|
|
|
|
|
|
nodeTestResult?: any
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-01-19 17:52:29 +08:00
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'execution-status': [status: any]
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
const messages = ref<Message[]>([])
|
|
|
|
|
|
const inputMessage = ref('')
|
|
|
|
|
|
const loading = ref(false)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const historyLoading = ref(false)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
const messagesContainer = ref<HTMLElement>()
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
|
|
const pendingAttachments = ref<PendingAttachment[]>([])
|
|
|
|
|
|
/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl) */
|
|
|
|
|
|
const blobsPendingRevokeAfterRun = ref<PendingAttachment[] | null>(null)
|
2026-04-13 22:52:36 +08:00
|
|
|
|
/** 拖放图片到输入区时高亮 */
|
|
|
|
|
|
const inputDragOver = ref(false)
|
|
|
|
|
|
const imagePreviewVisible = ref(false)
|
|
|
|
|
|
const imagePreviewSrc = ref('')
|
|
|
|
|
|
const imagePreviewTitle = ref('')
|
2026-01-19 17:52:29 +08:00
|
|
|
|
let pollingInterval: any = null
|
2026-01-22 09:59:02 +08:00
|
|
|
|
let replyAdded = false // 标志位:防止重复添加回复
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
/** 设计器预览对话本地镜像(同浏览器同 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,
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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
|
2026-04-13 20:17:18 +08:00
|
|
|
|
}))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writeDesignChatToStorage(agentId: string, list: Message[]) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const slice = list.slice(-MAX_PERSIST_MESSAGES)
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 11:31:48 +08:00
|
|
|
|
/** 本轮发送前已有对话(不含即将追加的当前用户句),供后端注入 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 ?? '')
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
/** 未登录时的浏览器会话 id(见 agent记忆实现方案.md) */
|
2026-04-09 21:58:53 +08:00
|
|
|
|
function getPreviewSessionUserId(agentId: string): string {
|
|
|
|
|
|
const key = `agent_preview_uid_${agentId}`
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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)}`
|
2026-04-09 21:58:53 +08:00
|
|
|
|
try {
|
|
|
|
|
|
let id = localStorage.getItem(key)
|
|
|
|
|
|
if (!id) {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
id = newId()
|
2026-04-09 21:58:53 +08:00
|
|
|
|
localStorage.setItem(key, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
return id
|
|
|
|
|
|
} catch {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
// 勿用 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
|
2026-04-13 22:52:36 +08:00
|
|
|
|
await hydrateMessageAttachmentThumbs(messages.value)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
nextTick(() => scrollToBottom())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const applyServerTurns = (
|
|
|
|
|
|
turns: Array<{
|
|
|
|
|
|
user_text: string
|
|
|
|
|
|
agent_text: string
|
|
|
|
|
|
created_at: string
|
|
|
|
|
|
execution_id: string
|
2026-04-13 22:52:36 +08:00
|
|
|
|
attachments?: Array<{
|
|
|
|
|
|
relative_path: string
|
|
|
|
|
|
filename: string
|
|
|
|
|
|
content_type?: string
|
|
|
|
|
|
}>
|
2026-04-13 20:17:18 +08:00
|
|
|
|
}>
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const next: Message[] = []
|
|
|
|
|
|
for (const t of turns) {
|
|
|
|
|
|
const base = new Date(t.created_at).getTime()
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}))
|
2026-04-13 20:17:18 +08:00
|
|
|
|
next.push({
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: t.user_text || ' ',
|
2026-04-13 22:52:36 +08:00
|
|
|
|
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400,
|
|
|
|
|
|
attachments: msgAttachments.length ? msgAttachments : undefined
|
2026-04-13 20:17:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
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
|
2026-04-13 22:52:36 +08:00
|
|
|
|
attachments?: Array<{
|
|
|
|
|
|
relative_path: string
|
|
|
|
|
|
filename: string
|
|
|
|
|
|
content_type?: string
|
|
|
|
|
|
}>
|
2026-04-13 20:17:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-13 22:52:36 +08:00
|
|
|
|
revokeMessageAttachmentBlobs(messages.value)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
messages.value = next
|
2026-04-13 22:52:36 +08:00
|
|
|
|
await hydrateMessageAttachmentThumbs(messages.value)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
/** 与回形针选择、拖放共用:上传到预览区并加入待发送列表 */
|
|
|
|
|
|
async function uploadPreviewFiles(fileList: File[]) {
|
|
|
|
|
|
if (!fileList.length) return
|
2026-04-13 20:17:18 +08:00
|
|
|
|
if (!props.agentId) {
|
|
|
|
|
|
ElMessage.warning('请先选中要预览的智能体后再上传附件')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const uploadedNames: string[] = []
|
2026-04-13 22:52:36 +08:00
|
|
|
|
for (const file of fileList) {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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,
|
2026-04-13 22:52:36 +08:00
|
|
|
|
content_type: (res.data as any)?.content_type,
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-04-09 21:58:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
// 发送消息
|
|
|
|
|
|
const handleSendMessage = async () => {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
const userMessage = inputMessage.value.trim()
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const attachSnap = pendingAttachments.value.slice()
|
|
|
|
|
|
pendingAttachments.value = []
|
2026-01-19 00:09:36 +08:00
|
|
|
|
inputMessage.value = ''
|
2026-04-13 20:17:18 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-01 11:31:48 +08:00
|
|
|
|
const conversation_history = conversationHistoryPayloadBeforeNewUserTurn()
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
// 添加用户消息
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}))
|
2026-01-19 00:09:36 +08:00
|
|
|
|
messages.value.push({
|
|
|
|
|
|
role: 'user',
|
2026-04-13 20:17:18 +08:00
|
|
|
|
content: userBubble || '(附件)',
|
2026-04-13 22:52:36 +08:00
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
attachments: userAttachments.length ? userAttachments : undefined
|
2026-01-19 00:09:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
|
|
|
|
|
|
// 发送到Agent
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
2026-05-01 11:31:48 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
)
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
const execution = response.data
|
2026-04-13 20:17:18 +08:00
|
|
|
|
blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
2026-01-22 09:59:02 +08:00
|
|
|
|
// 重置标志位
|
|
|
|
|
|
replyAdded = false
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
// 轮询执行状态
|
|
|
|
|
|
const checkStatus = async () => {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const finishBlobs = () => {
|
|
|
|
|
|
if (blobsPendingRevokeAfterRun.value) {
|
2026-04-13 22:52:36 +08:00
|
|
|
|
const revokable = blobsPendingRevokeAfterRun.value.filter(
|
|
|
|
|
|
(x) => !x.thumbUrl || !isThumbInMessages(x.thumbUrl)
|
|
|
|
|
|
)
|
|
|
|
|
|
revokeAttachmentBlobs(revokable)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
blobsPendingRevokeAfterRun.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
try {
|
2026-01-22 09:59:02 +08:00
|
|
|
|
// 如果已经添加过回复,直接返回,避免重复添加
|
|
|
|
|
|
if (replyAdded) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 17:52:29 +08:00
|
|
|
|
// 获取详细执行状态(包含节点执行信息)
|
2026-05-01 11:31:48 +08:00
|
|
|
|
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`, {
|
|
|
|
|
|
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
|
|
|
|
|
|
})
|
2026-01-19 17:52:29 +08:00
|
|
|
|
const status = statusResponse.data
|
|
|
|
|
|
|
|
|
|
|
|
// 将执行状态传递给父组件,用于显示工作流动画
|
|
|
|
|
|
emit('execution-status', status)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取执行详情(用于提取输出结果)
|
2026-05-01 11:31:48 +08:00
|
|
|
|
const execResponse = await api.get(`/api/v1/executions/${execution.id}`, {
|
|
|
|
|
|
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
|
|
|
|
|
|
})
|
2026-01-19 17:52:29 +08:00
|
|
|
|
const exec = execResponse.data
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
if (exec.status === 'completed') {
|
2026-01-22 09:59:02 +08:00
|
|
|
|
// 防止重复添加:如果已经添加过回复,直接返回
|
|
|
|
|
|
if (replyAdded) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标记已添加回复
|
|
|
|
|
|
replyAdded = true
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
// 提取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()
|
2026-01-19 17:52:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 延迟清除执行状态,让用户能看到最终的执行结果
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
emit('execution-status', null)
|
|
|
|
|
|
}, 3000) // 3秒后清除
|
|
|
|
|
|
|
|
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
2026-04-13 20:17:18 +08:00
|
|
|
|
finishBlobs()
|
2026-01-19 00:09:36 +08:00
|
|
|
|
} else if (exec.status === 'failed') {
|
2026-01-22 09:59:02 +08:00
|
|
|
|
// 防止重复添加:如果已经添加过回复,直接返回
|
|
|
|
|
|
if (replyAdded) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标记已添加回复
|
|
|
|
|
|
replyAdded = true
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
messages.value.push({
|
|
|
|
|
|
role: 'agent',
|
|
|
|
|
|
content: `执行失败: ${exec.error_message || '未知错误'}`,
|
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
|
})
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
scrollToBottom()
|
2026-01-19 17:52:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 延迟清除执行状态,让用户能看到失败节点的状态
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
emit('execution-status', null)
|
|
|
|
|
|
}, 5000) // 5秒后清除
|
|
|
|
|
|
|
|
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
2026-04-13 20:17:18 +08:00
|
|
|
|
finishBlobs()
|
2026-01-19 00:09:36 +08:00
|
|
|
|
} else {
|
2026-01-19 17:52:29 +08:00
|
|
|
|
// 继续轮询(pending 或 running 状态)
|
|
|
|
|
|
// 不需要做任何操作,等待下次轮询
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
2026-01-22 09:59:02 +08:00
|
|
|
|
// 防止重复添加:如果已经添加过回复,直接返回
|
|
|
|
|
|
if (replyAdded) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标记已添加回复
|
|
|
|
|
|
replyAdded = true
|
2026-04-13 20:17:18 +08:00
|
|
|
|
finishBlobs()
|
2026-01-22 09:59:02 +08:00
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const st = error.response?.status
|
|
|
|
|
|
const msg401 =
|
|
|
|
|
|
st === 401
|
|
|
|
|
|
? '登录已过期或未登录(401)。请重新登录后再试预览对话。'
|
|
|
|
|
|
: error.response?.data?.detail || error.message
|
2026-01-19 00:09:36 +08:00
|
|
|
|
messages.value.push({
|
|
|
|
|
|
role: 'agent',
|
2026-04-13 20:17:18 +08:00
|
|
|
|
content: `获取执行结果失败:${msg401}`,
|
2026-01-19 00:09:36 +08:00
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
|
})
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
scrollToBottom()
|
2026-01-19 17:52:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 清除执行状态
|
|
|
|
|
|
emit('execution-status', null)
|
|
|
|
|
|
|
|
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 17:52:29 +08:00
|
|
|
|
// 使用 setInterval 进行轮询,每500毫秒检查一次(更频繁,能捕获快速执行的节点)
|
|
|
|
|
|
pollingInterval = setInterval(checkStatus, 500)
|
|
|
|
|
|
// 立即执行一次
|
|
|
|
|
|
checkStatus()
|
2026-01-19 00:09:36 +08:00
|
|
|
|
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('发送消息失败:', error)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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()
|
2026-01-19 00:09:36 +08:00
|
|
|
|
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 = () => {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
revokeAttachmentBlobs([...pendingAttachments.value])
|
2026-04-13 22:52:36 +08:00
|
|
|
|
revokeMessageAttachmentBlobs(messages.value)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
pendingAttachments.value = []
|
|
|
|
|
|
if (blobsPendingRevokeAfterRun.value) {
|
|
|
|
|
|
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
|
|
|
|
|
blobsPendingRevokeAfterRun.value = null
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
messages.value = []
|
2026-04-13 20:17:18 +08:00
|
|
|
|
if (props.agentId) clearDesignChatStorage(props.agentId)
|
|
|
|
|
|
ElMessage.info('已清空预览对话(含本机缓存);刷新后不再恢复本条记录')
|
2026-01-19 17:52:29 +08:00
|
|
|
|
// 清除执行状态
|
|
|
|
|
|
emit('execution-status', null)
|
|
|
|
|
|
// 清除轮询
|
|
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (messagesContainer.value) {
|
|
|
|
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 11:44:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 知你等工作流约定「自然语言 + 末尾单行 JSON(intent/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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
// 格式化消息(支持简单的Markdown)
|
|
|
|
|
|
const formatMessage = (content: string) => {
|
|
|
|
|
|
if (!content) return ''
|
2026-04-13 20:17:18 +08:00
|
|
|
|
const noProtocol = stripDsmlLines(content)
|
|
|
|
|
|
const display = stripTrailingWorkflowJsonLine(noProtocol)
|
2026-04-08 11:44:24 +08:00
|
|
|
|
return display.replace(/\n/g, '<br>')
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化时间
|
|
|
|
|
|
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 = () => {
|
|
|
|
|
|
// 通过事件通知父组件清除测试结果
|
|
|
|
|
|
// 这里暂时不做处理,由父组件自动清除
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
// 监听消息变化,自动滚动 + 持久化到本机(设计器刷新可恢复)
|
|
|
|
|
|
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()
|
|
|
|
|
|
})
|
2026-01-19 17:52:29 +08:00
|
|
|
|
|
|
|
|
|
|
// 组件卸载时清理轮询
|
|
|
|
|
|
onUnmounted(() => {
|
2026-04-13 20:17:18 +08:00
|
|
|
|
window.removeEventListener('pageshow', onPageShow as EventListener)
|
2026-01-19 17:52:29 +08:00
|
|
|
|
if (pollingInterval) {
|
|
|
|
|
|
clearInterval(pollingInterval)
|
|
|
|
|
|
pollingInterval = null
|
|
|
|
|
|
}
|
2026-04-13 20:17:18 +08:00
|
|
|
|
revokeAttachmentBlobs([...pendingAttachments.value])
|
2026-04-13 22:52:36 +08:00
|
|
|
|
revokeMessageAttachmentBlobs(messages.value)
|
2026-04-13 20:17:18 +08:00
|
|
|
|
if (blobsPendingRevokeAfterRun.value) {
|
|
|
|
|
|
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
|
|
|
|
|
blobsPendingRevokeAfterRun.value = null
|
|
|
|
|
|
}
|
2026-01-19 17:52:29 +08:00
|
|
|
|
// 清除执行状态
|
|
|
|
|
|
emit('execution-status', null)
|
|
|
|
|
|
})
|
2026-01-19 00:09:36 +08:00
|
|
|
|
</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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:09:36 +08:00
|
|
|
|
.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;
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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;
|
2026-01-19 00:09:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-04-13 20:17:18 +08:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-01-19 00:09:36 +08:00
|
|
|
|
</style>
|