refactor: 移除导出对话功能,如有需要建议调用接口自行渲染
This commit is contained in:
parent
00a4a0cdab
commit
48d4a6b36f
@ -1,435 +0,0 @@
|
|||||||
import { escapeHtml } from '@/utils/html'
|
|
||||||
import { renderMarkdown as renderMarkdownPreview } from '@/utils/markdown_preview'
|
|
||||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
|
||||||
import chatExportTemplate from './templates/chat-export-template.html?raw'
|
|
||||||
|
|
||||||
export class ChatExporter {
|
|
||||||
/**
|
|
||||||
* 导出聊天对话为 HTML 文件
|
|
||||||
* @param {Object} options 导出选项
|
|
||||||
*/
|
|
||||||
static async exportToHTML(options = {}) {
|
|
||||||
const {
|
|
||||||
chatTitle = '新对话',
|
|
||||||
agentName = '智能助手',
|
|
||||||
agentDescription = '',
|
|
||||||
messages = []
|
|
||||||
} = options || {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const htmlContent = await this.generateHTML({
|
|
||||||
chatTitle,
|
|
||||||
agentName,
|
|
||||||
agentDescription,
|
|
||||||
messages
|
|
||||||
})
|
|
||||||
|
|
||||||
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
const timestamp = dayjs().tz('Asia/Shanghai').format('YYYYMMDD-HHmmss')
|
|
||||||
const safeTitle = chatTitle.replace(/[\\/:*?"<>|]/g, '_')
|
|
||||||
const filename = `${safeTitle}-${timestamp}.html`
|
|
||||||
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
link.style.display = 'none'
|
|
||||||
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
return { success: true, filename }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('导出对话失败:', error)
|
|
||||||
throw new Error(`导出失败: ${error.message}`, { cause: error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成完整 HTML 内容
|
|
||||||
*/
|
|
||||||
static async generateHTML(options) {
|
|
||||||
const { chatTitle, agentName, agentDescription, messages } = options
|
|
||||||
|
|
||||||
const flattenedMessages = this.flattenMessages(messages)
|
|
||||||
if (flattenedMessages.length === 0) {
|
|
||||||
throw new Error('没有可导出的对话内容')
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesHTML = await this.generateMessagesHTML(flattenedMessages, agentName)
|
|
||||||
|
|
||||||
return this.generateHTMLTemplate({
|
|
||||||
chatTitle,
|
|
||||||
agentName,
|
|
||||||
agentDescription,
|
|
||||||
exportTime: dayjs().tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss'),
|
|
||||||
messagesHTML
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扁平化消息列表
|
|
||||||
*/
|
|
||||||
static flattenMessages(messages = []) {
|
|
||||||
const result = []
|
|
||||||
|
|
||||||
console.log('[ChatExporter] flattenMessages input:', {
|
|
||||||
messagesLength: messages?.length || 0,
|
|
||||||
messagesType: Array.isArray(messages) ? 'array' : typeof messages,
|
|
||||||
firstMessage: messages?.[0]
|
|
||||||
? {
|
|
||||||
hasMessages: Array.isArray(messages[0].messages),
|
|
||||||
hasType: !!messages[0].type,
|
|
||||||
hasRole: !!messages[0].role,
|
|
||||||
hasContent: !!messages[0].content,
|
|
||||||
keys: Object.keys(messages[0])
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
})
|
|
||||||
;(messages || []).forEach((item) => {
|
|
||||||
if (!item) return
|
|
||||||
|
|
||||||
if (Array.isArray(item.messages)) {
|
|
||||||
item.messages.forEach((msg) => {
|
|
||||||
if (msg) result.push(msg)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 支持直接传入消息扁平数组
|
|
||||||
if (item.type || item.role || item.content) {
|
|
||||||
result.push(item)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成对话消息的 HTML 片段
|
|
||||||
*/
|
|
||||||
static async generateMessagesHTML(messages, agentName) {
|
|
||||||
const messageSegments = await Promise.all(
|
|
||||||
messages.map(async (msg) => {
|
|
||||||
const isUserMessage = ['human', 'user'].includes(msg?.type) || msg?.role === 'user'
|
|
||||||
const avatar = isUserMessage ? '👤' : '🤖'
|
|
||||||
const senderLabel = isUserMessage ? '用户' : agentName || '智能助手'
|
|
||||||
const messageClass = isUserMessage ? 'user-message' : 'ai-message'
|
|
||||||
const timestampRaw = this.getMessageTimestamp(msg)
|
|
||||||
const timestamp = this.escapeHtml(this.formatTimestamp(timestampRaw))
|
|
||||||
|
|
||||||
const { content, reasoning } = this.extractMessageContent(msg)
|
|
||||||
const [contentHTML, reasoningHTML] = await Promise.all([
|
|
||||||
content ? this.renderMarkdown(content) : '',
|
|
||||||
!isUserMessage ? this.generateReasoningHTML(reasoning) : ''
|
|
||||||
])
|
|
||||||
const toolCallsHTML = !isUserMessage ? this.generateToolCallsHTML(msg) : ''
|
|
||||||
|
|
||||||
const bodySegments = [
|
|
||||||
reasoningHTML,
|
|
||||||
contentHTML ? `<div class="markdown-body">${contentHTML}</div>` : '',
|
|
||||||
toolCallsHTML
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="message ${messageClass}">
|
|
||||||
<div class="message-header">
|
|
||||||
<span class="avatar">${avatar}</span>
|
|
||||||
<span class="sender">${this.escapeHtml(senderLabel)}</span>
|
|
||||||
<span class="time">${timestamp}</span>
|
|
||||||
</div>
|
|
||||||
<div class="message-content">
|
|
||||||
${bodySegments.length > 0 ? bodySegments.join('') : '<div class="empty-message">(此消息暂无可展示内容)</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return messageSegments.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拆分消息内容与推理文本
|
|
||||||
*/
|
|
||||||
static extractMessageContent(msg = {}) {
|
|
||||||
const content = this.normalizeContent(msg?.content)
|
|
||||||
let reasoning = msg?.additional_kwargs?.reasoning_content || msg?.reasoning_content || ''
|
|
||||||
let visibleContent = content
|
|
||||||
|
|
||||||
if (!reasoning && content.includes('<think')) {
|
|
||||||
const thinkRegex = /<think>([\s\S]*?)<\/think>|<think>([\s\S]*)$/i
|
|
||||||
const match = content.match(thinkRegex)
|
|
||||||
if (match) {
|
|
||||||
reasoning = (match[1] || match[2] || '').trim()
|
|
||||||
visibleContent = content.replace(match[0], '').trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: visibleContent,
|
|
||||||
reasoning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准化消息内容
|
|
||||||
*/
|
|
||||||
static normalizeContent(raw) {
|
|
||||||
if (raw == null) return ''
|
|
||||||
if (typeof raw === 'string') return raw
|
|
||||||
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
return raw
|
|
||||||
.map((item) => {
|
|
||||||
if (!item) return ''
|
|
||||||
if (typeof item === 'string') return item
|
|
||||||
if (typeof item === 'object') {
|
|
||||||
return item.text || item.content || item.value || ''
|
|
||||||
}
|
|
||||||
return String(item)
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof raw === 'object') {
|
|
||||||
if (typeof raw.text === 'string') return raw.text
|
|
||||||
if (typeof raw.content === 'string') return raw.content
|
|
||||||
if (Array.isArray(raw.content)) return this.normalizeContent(raw.content)
|
|
||||||
try {
|
|
||||||
return JSON.stringify(raw, null, 2)
|
|
||||||
} catch {
|
|
||||||
return String(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成推理过程 HTML
|
|
||||||
*/
|
|
||||||
static async generateReasoningHTML(reasoning) {
|
|
||||||
if (!reasoning) return ''
|
|
||||||
|
|
||||||
const reasoningHTML = await this.renderMarkdown(reasoning)
|
|
||||||
if (!reasoningHTML) return ''
|
|
||||||
|
|
||||||
return `
|
|
||||||
<details class="reasoning-section">
|
|
||||||
<summary class="reasoning-summary">💭 思考过程</summary>
|
|
||||||
<div class="reasoning-content markdown-body">
|
|
||||||
${reasoningHTML}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成工具调用 HTML
|
|
||||||
*/
|
|
||||||
static generateToolCallsHTML(msg = {}) {
|
|
||||||
const toolCalls = this.normalizeToolCalls(msg)
|
|
||||||
if (toolCalls.length === 0) return ''
|
|
||||||
|
|
||||||
const sections = toolCalls
|
|
||||||
.map((toolCall) => {
|
|
||||||
const toolName = this.escapeHtml(toolCall?.function?.name || toolCall?.name || '工具调用')
|
|
||||||
const argsSource = toolCall?.args ?? toolCall?.function?.arguments
|
|
||||||
const args = this.stringifyToolArgs(argsSource)
|
|
||||||
const result = this.normalizeToolResult(toolCall?.tool_call_result?.content)
|
|
||||||
const isFinished = toolCall?.status === 'success'
|
|
||||||
const stateClass = isFinished ? 'done' : 'pending'
|
|
||||||
const stateLabel = isFinished ? '已完成' : '执行中'
|
|
||||||
|
|
||||||
return `
|
|
||||||
<details class="tool-call" ${isFinished ? '' : 'open'}>
|
|
||||||
<summary>
|
|
||||||
<span class="tool-call-title">🔧 ${toolName}</span>
|
|
||||||
<span class="tool-call-state ${stateClass}">${stateLabel}</span>
|
|
||||||
</summary>
|
|
||||||
<div class="tool-call-body">
|
|
||||||
${
|
|
||||||
args
|
|
||||||
? `
|
|
||||||
<div class="tool-call-args">
|
|
||||||
<strong>参数</strong>
|
|
||||||
<pre>${this.escapeHtml(args)}</pre>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
${
|
|
||||||
isFinished && result
|
|
||||||
? `
|
|
||||||
<div class="tool-call-result">
|
|
||||||
<strong>结果</strong>
|
|
||||||
<pre>${this.escapeHtml(result)}</pre>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
return `<div class="tool-calls">${sections}</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
static normalizeToolCalls(msg = {}) {
|
|
||||||
const rawCalls = msg.tool_calls || msg.additional_kwargs?.tool_calls
|
|
||||||
if (!rawCalls) return []
|
|
||||||
if (Array.isArray(rawCalls)) return rawCalls.filter(Boolean)
|
|
||||||
if (typeof rawCalls === 'object') {
|
|
||||||
return Object.values(rawCalls).filter(Boolean)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static stringifyToolArgs(rawArgs) {
|
|
||||||
if (rawArgs == null || rawArgs === '') return ''
|
|
||||||
|
|
||||||
if (typeof rawArgs === 'string') {
|
|
||||||
const trimmed = rawArgs.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(trimmed), null, 2)
|
|
||||||
} catch {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawArgs === 'object') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(rawArgs, null, 2)
|
|
||||||
} catch {
|
|
||||||
return String(rawArgs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(rawArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
static normalizeToolResult(result) {
|
|
||||||
if (!result) return ''
|
|
||||||
if (typeof result === 'string') return result.trim()
|
|
||||||
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
return result
|
|
||||||
.map((item) => {
|
|
||||||
if (!item) return ''
|
|
||||||
if (typeof item === 'string') return item
|
|
||||||
if (typeof item === 'object') {
|
|
||||||
return item.text || item.content || JSON.stringify(item, null, 2)
|
|
||||||
}
|
|
||||||
return String(item)
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n\n')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof result === 'object') {
|
|
||||||
if (typeof result.content !== 'undefined') {
|
|
||||||
return this.normalizeToolResult(result.content)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(result, null, 2)
|
|
||||||
} catch {
|
|
||||||
return String(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的 Markdown 渲染,失败时回退到简单换行
|
|
||||||
*/
|
|
||||||
static async renderMarkdown(content) {
|
|
||||||
if (!content) return ''
|
|
||||||
try {
|
|
||||||
return (await renderMarkdownPreview(content)).trim()
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Markdown 渲染失败,回退为纯文本:', error)
|
|
||||||
return this.escapeHtml(content).replace(/\n/g, '<br>')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static escapeHtml(value) {
|
|
||||||
return escapeHtml(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取消息时间戳
|
|
||||||
*/
|
|
||||||
static getMessageTimestamp(msg = {}) {
|
|
||||||
const candidates = [
|
|
||||||
msg.timestamp,
|
|
||||||
msg.created_at,
|
|
||||||
msg.createdAt,
|
|
||||||
msg.createdTime,
|
|
||||||
msg.time,
|
|
||||||
msg.datetime,
|
|
||||||
msg.date,
|
|
||||||
msg.additional_kwargs?.timestamp,
|
|
||||||
msg.additional_kwargs?.created_at
|
|
||||||
]
|
|
||||||
|
|
||||||
return candidates.find((value) => value !== undefined && value !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化时间戳
|
|
||||||
*/
|
|
||||||
static formatTimestamp(raw) {
|
|
||||||
const fallback = dayjs().tz('Asia/Shanghai')
|
|
||||||
|
|
||||||
if (raw instanceof Date) {
|
|
||||||
return dayjs(raw).tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw || raw === 0) {
|
|
||||||
if (typeof raw === 'number') {
|
|
||||||
const value = raw < 1e12 ? raw * 1000 : raw
|
|
||||||
return dayjs(value).tz('Asia/Shanghai').format('YYYY年MM月DD日 HH:mm:ss')
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseToShanghai(raw)
|
|
||||||
if (parsed) {
|
|
||||||
return parsed.format('YYYY年MM月DD日 HH:mm:ss')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback.format('YYYY年MM月DD日 HH:mm:ss')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成完整 HTML 文档骨架
|
|
||||||
*/
|
|
||||||
static generateHTMLTemplate(options) {
|
|
||||||
const { chatTitle, agentName, agentDescription, exportTime, messagesHTML } = options
|
|
||||||
|
|
||||||
const safeTitle = this.escapeHtml(chatTitle)
|
|
||||||
const safeAgentName = this.escapeHtml(agentName)
|
|
||||||
const safeDescription = this.escapeHtml(agentDescription).replace(/\n/g, '<br>')
|
|
||||||
const safeExportTime = this.escapeHtml(exportTime)
|
|
||||||
|
|
||||||
const descriptionBlock = agentDescription ? `<br><strong>描述:</strong> ${safeDescription}` : ''
|
|
||||||
|
|
||||||
return chatExportTemplate
|
|
||||||
.replace(/{{TITLE}}/g, safeTitle)
|
|
||||||
.replace('{{AGENT_NAME}}', safeAgentName)
|
|
||||||
.replace('{{DESCRIPTION_BLOCK}}', descriptionBlock)
|
|
||||||
.replace('{{EXPORT_TIME}}', safeExportTime)
|
|
||||||
.replace('{{MESSAGES}}', messagesHTML)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ChatExporter
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{{TITLE}} - 对话导出</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
|
||||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #212529;
|
|
||||||
background-color: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 40px auto;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
box-shadow: 0 18px 40px -24px rgba(15, 23, 42, 0.45);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
padding: 32px 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: #1e293b;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .agent-info {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #475569;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .export-info {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px dashed #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages {
|
|
||||||
padding: 32px 40px 48px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
padding: 18px 22px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
|
||||||
transition:
|
|
||||||
border-color 0.2s ease,
|
|
||||||
box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content > *:not(:last-child) {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message .message-content {
|
|
||||||
background: linear-gradient(180deg, #e0f2fe 0%, #f0f9ff 100%);
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-message .sender {
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-message .message-content {
|
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body p {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body ul,
|
|
||||||
.markdown-body ol {
|
|
||||||
padding-left: 24px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body li {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body pre {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body code {
|
|
||||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
||||||
background: rgba(15, 23, 42, 0.08);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-section {
|
|
||||||
border: 1px solid #cbd5f5;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(79, 70, 229, 0.04);
|
|
||||||
padding: 14px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-summary {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4338ca;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-summary::after {
|
|
||||||
content: '▼';
|
|
||||||
font-size: 0.8em;
|
|
||||||
margin-left: 6px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-section[open] .reasoning-summary::after {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-content {
|
|
||||||
margin-top: 14px;
|
|
||||||
border-top: 1px dashed rgba(79, 70, 229, 0.2);
|
|
||||||
padding-top: 14px;
|
|
||||||
color: #312e81;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-calls {
|
|
||||||
border: 1px solid #cbd5f5;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(59, 130, 246, 0.04);
|
|
||||||
padding: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call {
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(59, 130, 246, 0.08);
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call > summary {
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call > summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call > summary::after {
|
|
||||||
content: '▼';
|
|
||||||
font-size: 0.8em;
|
|
||||||
margin-left: 8px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
color: rgba(29, 78, 216, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call[open] > summary::after {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-body {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-state {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-state.done {
|
|
||||||
border-color: rgba(34, 197, 94, 0.3);
|
|
||||||
background: rgba(22, 163, 74, 0.1);
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-state.pending {
|
|
||||||
border-color: rgba(251, 191, 36, 0.3);
|
|
||||||
background: rgba(245, 158, 11, 0.1);
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call pre {
|
|
||||||
background: rgba(15, 23, 42, 0.9);
|
|
||||||
color: rgba(248, 250, 252, 0.9);
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
overflow-x: auto;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-message {
|
|
||||||
font-style: italic;
|
|
||||||
color: #94a3b8;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
padding: 18px 24px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #94a3b8;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer .brand {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4338ca;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>{{TITLE}}</h1>
|
|
||||||
<div class="agent-info"><strong>智能体:</strong> {{AGENT_NAME}} {{DESCRIPTION_BLOCK}}</div>
|
|
||||||
<div class="export-info">导出时间: {{EXPORT_TIME}}</div>
|
|
||||||
</div>
|
|
||||||
<div class="messages">{{MESSAGES}}</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>此对话由 <span class="brand">智能助手平台</span> 导出</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -87,79 +87,30 @@
|
|||||||
<FolderKanban size="18" class="nav-btn-icon" />
|
<FolderKanban size="18" class="nav-btn-icon" />
|
||||||
<span class="hide-text">文件</span>
|
<span class="hide-text">文件</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="userStore.isAdmin && selectedAgentId"
|
|
||||||
ref="moreButtonRef"
|
|
||||||
type="button"
|
|
||||||
class="agent-nav-btn"
|
|
||||||
@click.stop="toggleMoreMenu"
|
|
||||||
>
|
|
||||||
<Ellipsis size="18" class="nav-btn-icon" />
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</AgentChatComponent>
|
</AgentChatComponent>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 反馈模态框 -->
|
|
||||||
<FeedbackModalComponent
|
|
||||||
v-if="userStore.isAdmin"
|
|
||||||
ref="feedbackModal"
|
|
||||||
:agent-id="selectedAgentId"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 自定义更多菜单 -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="menu-fade">
|
|
||||||
<div
|
|
||||||
v-if="userStore.isAdmin && chatUIStore.moreMenuOpen"
|
|
||||||
ref="moreMenuRef"
|
|
||||||
class="more-popup-menu"
|
|
||||||
:style="{
|
|
||||||
left: chatUIStore.moreMenuPosition.x + 'px',
|
|
||||||
top: chatUIStore.moreMenuPosition.y + 'px'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="menu-item" @click="handleShareChat">
|
|
||||||
<ShareAltOutlined class="menu-icon" />
|
|
||||||
<span class="menu-text">分享对话</span>
|
|
||||||
</div>
|
|
||||||
<div class="menu-item" @click="handleFeedback">
|
|
||||||
<MessageOutlined class="menu-icon" />
|
|
||||||
<span class="menu-text">查看反馈</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { MessageOutlined, ShareAltOutlined } from '@ant-design/icons-vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { Settings2, Ellipsis, ChevronDown, Check, FolderKanban } from 'lucide-vue-next'
|
import { Settings2, ChevronDown, Check, FolderKanban } from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import AgentChatComponent from '@/components/AgentChatComponent.vue'
|
import AgentChatComponent from '@/components/AgentChatComponent.vue'
|
||||||
import FeedbackModalComponent from '@/components/dashboard/FeedbackModalComponent.vue'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
|
||||||
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
|
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
|
||||||
import { useChatUIStore } from '@/stores/chatUI'
|
|
||||||
import { ChatExporter } from '@/utils/chatExporter'
|
|
||||||
import { handleChatError } from '@/utils/errorHandler'
|
import { handleChatError } from '@/utils/errorHandler'
|
||||||
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
import { onClickOutside } from '@vueuse/core'
|
|
||||||
|
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
// 组件引用
|
// 组件引用
|
||||||
const feedbackModal = ref(null)
|
|
||||||
const chatComponentRef = ref(null)
|
const chatComponentRef = ref(null)
|
||||||
|
|
||||||
// Stores
|
// Stores
|
||||||
const userStore = useUserStore()
|
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
const chatUIStore = useChatUIStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -264,80 +215,6 @@ const openAgentManagement = () => {
|
|||||||
router.push({ name: 'ModelManageComp', query: { tab: 'agents' } })
|
router.push({ name: 'ModelManageComp', query: { tab: 'agents' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更多菜单相关
|
|
||||||
const moreMenuRef = ref(null)
|
|
||||||
const moreButtonRef = ref(null)
|
|
||||||
|
|
||||||
const toggleMoreMenu = (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
// 切换状态,而不是只打开
|
|
||||||
chatUIStore.moreMenuOpen = !chatUIStore.moreMenuOpen
|
|
||||||
|
|
||||||
if (chatUIStore.moreMenuOpen) {
|
|
||||||
// 只在打开时计算位置
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
|
||||||
chatUIStore.openMoreMenu(rect.right - 110, rect.bottom + 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMoreMenu = () => {
|
|
||||||
chatUIStore.closeMoreMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 VueUse 的 onClickOutside
|
|
||||||
onClickOutside(
|
|
||||||
moreMenuRef,
|
|
||||||
() => {
|
|
||||||
if (chatUIStore.moreMenuOpen) {
|
|
||||||
closeMoreMenu()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ ignore: [moreButtonRef] }
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleShareChat = async () => {
|
|
||||||
closeMoreMenu()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 从聊天组件获取导出数据
|
|
||||||
const exportData = chatComponentRef.value?.getExportPayload?.()
|
|
||||||
|
|
||||||
console.log('[AgentView] Export data:', exportData)
|
|
||||||
|
|
||||||
if (!exportData) {
|
|
||||||
message.warning('当前没有可导出的对话内容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有实际的消息内容
|
|
||||||
const hasMessages = exportData.messages && exportData.messages.length > 0
|
|
||||||
const hasOngoingMessages = exportData.onGoingMessages && exportData.onGoingMessages.length > 0
|
|
||||||
|
|
||||||
if (!hasMessages && !hasOngoingMessages) {
|
|
||||||
console.warn('[AgentView] Export data has no messages:', {
|
|
||||||
messages: exportData.messages,
|
|
||||||
onGoingMessages: exportData.onGoingMessages
|
|
||||||
})
|
|
||||||
message.warning('当前对话暂无内容可导出,请先进行对话')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ChatExporter.exportToHTML(exportData)
|
|
||||||
message.success(`对话已导出为HTML文件: ${result.filename}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AgentView] Export error:', error)
|
|
||||||
if (error?.message?.includes('没有可导出的对话内容')) {
|
|
||||||
message.warning('当前对话暂无内容可导出,请先进行对话')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleChatError(error, 'export')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFeedback = () => {
|
|
||||||
closeMoreMenu()
|
|
||||||
feedbackModal.value?.show()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@ -397,107 +274,11 @@ const handleFeedback = () => {
|
|||||||
color: currentColor;
|
color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义更多菜单样式
|
|
||||||
.more-popup-menu {
|
|
||||||
position: fixed;
|
|
||||||
min-width: 100px;
|
|
||||||
background: var(--gray-0);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgba(0, 0, 0, 0.08),
|
|
||||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
border: 1px solid var(--gray-100);
|
|
||||||
padding: 4px;
|
|
||||||
z-index: 9999;
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--gray-900);
|
|
||||||
position: relative;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
.menu-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--gray-600);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-text {
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--gray-50);
|
|
||||||
// color: var(--main-700);
|
|
||||||
|
|
||||||
// .menu-icon {
|
|
||||||
// color: var(--main-600);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: var(--gray-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--gray-100);
|
|
||||||
margin: 4px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单淡入淡出动画
|
|
||||||
.menu-fade-enter-active {
|
|
||||||
animation: menuSlideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-fade-leave-active {
|
|
||||||
animation: menuSlideOut 0.15s cubic-bezier(0.4, 0, 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes menuSlideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes menuSlideOut {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式优化
|
// 响应式优化
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.config-dropdown-trigger {
|
.config-dropdown-trigger {
|
||||||
max-width: calc(100vw - 112px);
|
max-width: calc(100vw - 112px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-popup-menu {
|
|
||||||
box-shadow:
|
|
||||||
0 12px 32px rgba(0, 0, 0, 0.12),
|
|
||||||
0 4px 12px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user