|
|
@@ -1,14 +1,15 @@
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
import { useLocation, useParams } from 'react-router-dom'
|
|
|
import { Modal } from '@arco-design/web-react'
|
|
|
-import { getDifyAppParameters, getAppFullInfo, runWorkflow, runChatMessage, consumeSSE, uploadFile } from '@/service/dify'
|
|
|
-import { getBusinessConversations, getBusinessConversationDetail, downloadMessageContent } from '@/service/business'
|
|
|
+import { getDifyAppParameters, getAppFullInfo, runWorkflow, runChatMessage, consumeSSE, uploadFile, getChatMessages, getConversations } from '@/service/dify'
|
|
|
import { API_BASE_URL } from '@/config'
|
|
|
import agentEmptyStates from '@/config/agentEmptyStates.json'
|
|
|
import emojiMartData from '@emoji-mart/data'
|
|
|
import { marked } from 'marked'
|
|
|
import wordIcon from '@/assets/icons/word.svg'
|
|
|
import pdfIcon from '@/assets/icons/pdf.svg'
|
|
|
+import RichTextEditor from '@/components/RichTextEditor'
|
|
|
+import { htmlToDocxBlob } from '@/utils/exportDocx'
|
|
|
|
|
|
// 通过 emoji-mart data 把 emoji 名称转换为 native Unicode 字符
|
|
|
const emojiNameToNative = (name) => {
|
|
|
@@ -30,6 +31,50 @@ const buildIconUrl = (icon) => {
|
|
|
return icon
|
|
|
}
|
|
|
|
|
|
+// ===== AI 消息操作图标(内联 SVG,lucide 风格) =====
|
|
|
+const IconCopy = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <rect x="9" y="9" width="12" height="12" rx="2" />
|
|
|
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconVolume = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <path d="M11 5 6 9H2v6h4l5 4V5z" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconStop = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <rect x="6" y="6" width="12" height="12" rx="2" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconThumbUp = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <path d="M7 10v12" />
|
|
|
+ <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconThumbDown = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <path d="M17 14V2" />
|
|
|
+ <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconRefresh = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
|
|
+ <path d="M21 3v5h-5" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+const IconEdit = () => (
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
+ <path d="M12 20h9" />
|
|
|
+ <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
|
|
+ </svg>
|
|
|
+)
|
|
|
+
|
|
|
marked.setOptions({
|
|
|
gfm: true,
|
|
|
breaks: true,
|
|
|
@@ -101,13 +146,76 @@ const splitThinkContent = (text) => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 提取 AI 消息的正文(剔除思考过程与 <step> 过程提示)
|
|
|
+const getAnswerText = (message) => {
|
|
|
+ const parsed = splitThinkContent(message?.content || '')
|
|
|
+ const answer = parsed.answer || (!parsed.thought ? message?.content || '' : '')
|
|
|
+ return splitStepContent(answer).text.trim()
|
|
|
+}
|
|
|
+
|
|
|
+// 解析流式返回中的 <step>xxx</step> 过程提示(兼容 <step/>xxx</step> 写法与未闭合的进行中状态)
|
|
|
+const splitStepContent = (text) => {
|
|
|
+ const source = String(text || '')
|
|
|
+ const steps = []
|
|
|
+ let rest = source.replace(/<step\/?>([\s\S]*?)<\/step>/gi, (_, s) => {
|
|
|
+ const v = s.trim()
|
|
|
+ if (v) steps.push(v)
|
|
|
+ return ''
|
|
|
+ })
|
|
|
+ // 流式输出中尚未闭合的 step 段,视为进行中的步骤
|
|
|
+ rest = rest.replace(/<step\/?>([\s\S]*)$/i, (_, s) => {
|
|
|
+ const v = s.trim()
|
|
|
+ if (v) steps.push(v)
|
|
|
+ return ''
|
|
|
+ })
|
|
|
+ return { steps, text: rest }
|
|
|
+}
|
|
|
+
|
|
|
+// 流式期间当前展示的过程提示:pending 阶段返回占位文案,之后返回最新的 step 提示
|
|
|
+const getActiveStepHint = (message) => {
|
|
|
+ if (!message?.streaming) return ''
|
|
|
+ if (message.pending) return message.content || STREAM_WAITING_TEXT
|
|
|
+ const parsed = splitThinkContent(message?.content || '')
|
|
|
+ const answer = parsed.answer || (!parsed.thought ? message?.content || '' : '')
|
|
|
+ const { steps } = splitStepContent(answer)
|
|
|
+ return steps.length ? steps[steps.length - 1] : ''
|
|
|
+}
|
|
|
+
|
|
|
+// 复制文本到剪贴板(安全上下文走 Clipboard API,否则降级 execCommand)
|
|
|
+const copyTextToClipboard = async (text) => {
|
|
|
+ if (navigator.clipboard && window.isSecureContext) {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text)
|
|
|
+ return true
|
|
|
+ } catch (_) { /* 失败则继续降级 */ }
|
|
|
+ }
|
|
|
+ const ta = document.createElement('textarea')
|
|
|
+ ta.value = text
|
|
|
+ ta.setAttribute('readonly', '')
|
|
|
+ ta.style.position = 'fixed'
|
|
|
+ ta.style.top = '0'
|
|
|
+ ta.style.left = '-9999px'
|
|
|
+ ta.style.opacity = '0'
|
|
|
+ document.body.appendChild(ta)
|
|
|
+ ta.focus()
|
|
|
+ ta.select()
|
|
|
+ ta.setSelectionRange(0, ta.value.length)
|
|
|
+ let ok = false
|
|
|
+ try { ok = document.execCommand('copy') } catch (_) { ok = false }
|
|
|
+ document.body.removeChild(ta)
|
|
|
+ return ok
|
|
|
+}
|
|
|
+
|
|
|
const renderAssistantMessage = (message) => {
|
|
|
return <AssistantMessageContent message={message} />
|
|
|
}
|
|
|
|
|
|
function AssistantMessageContent({ message }) {
|
|
|
const parsed = splitThinkContent(message?.content || '')
|
|
|
- const answer = parsed.answer || (!parsed.thought ? message?.content : '')
|
|
|
+ const { text } = splitStepContent(parsed.answer || (!parsed.thought ? message?.content : ''))
|
|
|
+ const answer = message?.pending ? '' : text
|
|
|
+ // 过程提示始终置顶:pending 占位文案 / 最新 step 提示,随流式输出动态替换
|
|
|
+ const activeStep = getActiveStepHint(message)
|
|
|
const thinking = message?.thinking || parsed.thinking
|
|
|
const prevThinkingRef = useRef(thinking)
|
|
|
const [thinkOpen, setThinkOpen] = useState(thinking)
|
|
|
@@ -123,6 +231,12 @@ function AssistantMessageContent({ message }) {
|
|
|
|
|
|
return (
|
|
|
<>
|
|
|
+ {activeStep && (
|
|
|
+ <div className={`execute-msg-step-hint ${thinking ? 'is-pending' : ''}`} title={activeStep}>
|
|
|
+ <span className="execute-msg-step-spinner" />
|
|
|
+ <span className="execute-msg-step-label">{activeStep}</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
{parsed.thought && (
|
|
|
<details
|
|
|
className={`execute-msg-think ${thinking ? 'is-thinking' : ''}`}
|
|
|
@@ -172,6 +286,140 @@ const sanitizeDownloadName = (name) => {
|
|
|
return String(name || '智能体').replace(/[\\/:*?"<>|]/g, '_').trim() || '智能体'
|
|
|
}
|
|
|
|
|
|
+// 导出文档的正文样式(与编辑器基准一致:正文 14px、行高 1.8、标题按级别缩放)
|
|
|
+const EXPORT_DOC_STYLE = [
|
|
|
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.8;color:#1d2129;margin:0;}',
|
|
|
+ 'h1,h2,h3,h4,h5{margin:0.6em 0 0.3em;line-height:1.4;}',
|
|
|
+ 'h1{font-size:28px}h2{font-size:24px}h3{font-size:20px}h4{font-size:17px}h5{font-size:15px}',
|
|
|
+ 'p{margin:0.4em 0;}',
|
|
|
+ 'ul,ol{padding-left:1.6em;margin:0.4em 0;}',
|
|
|
+ 'blockquote{margin:0.5em 0;padding:0.2em 1em;border-left:3px solid #e5e6eb;color:#4e5969;background:#f7f8fa;}',
|
|
|
+ 'pre{background:#f2f3f5;padding:10px 12px;border-radius:6px;white-space:pre-wrap;margin:0.5em 0;}',
|
|
|
+ 'code{font-family:Consolas,Menlo,monospace;background:#f2f3f5;padding:1px 4px;border-radius:4px;}',
|
|
|
+ 'table{border-collapse:collapse;margin:0.5em 0;}td,th{border:1px solid #e5e6eb;padding:4px 8px;}',
|
|
|
+ 'img{max-width:100%;}',
|
|
|
+].join('')
|
|
|
+
|
|
|
+// 组装导出用完整 HTML(DOCX 走 altChunk 需要 head 内样式;PDF 截图渲染同样复用)
|
|
|
+const buildExportHtml = (bodyHtml, extraStyle = '') =>
|
|
|
+ `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${EXPORT_DOC_STYLE}${extraStyle}</style></head><body>${bodyHtml}</body></html>`
|
|
|
+
|
|
|
+// 触发浏览器下载(Blob)
|
|
|
+const downloadBlob = (blob, filename) => {
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = url
|
|
|
+ a.download = filename
|
|
|
+ document.body.appendChild(a)
|
|
|
+ a.click()
|
|
|
+ document.body.removeChild(a)
|
|
|
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
|
+}
|
|
|
+
|
|
|
+// HTML 直接导出为 PDF 文件下载(html2canvas 截图 + jsPDF 按 A4 切页),不再调起浏览器打印
|
|
|
+const exportHtmlToPdf = async (htmlSource, filename) => {
|
|
|
+ const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
|
|
+ import('html2canvas'),
|
|
|
+ import('jspdf'),
|
|
|
+ ])
|
|
|
+
|
|
|
+ // 离屏 iframe 渲染导出 HTML:宽度取 A4 @96dpi,保证与打印方案一致的排版基准
|
|
|
+ const A4_W = 794
|
|
|
+ const frame = document.createElement('iframe')
|
|
|
+ frame.setAttribute('aria-hidden', 'true')
|
|
|
+ Object.assign(frame.style, {
|
|
|
+ position: 'fixed', left: '-9999px', top: '0',
|
|
|
+ width: `${A4_W}px`, height: '200px', border: '0',
|
|
|
+ })
|
|
|
+ document.body.appendChild(frame)
|
|
|
+
|
|
|
+ try {
|
|
|
+ await new Promise((resolve, reject) => {
|
|
|
+ frame.onload = resolve
|
|
|
+ frame.onerror = () => reject(new Error('导出内容渲染失败'))
|
|
|
+ frame.srcdoc = htmlSource
|
|
|
+ })
|
|
|
+ const doc = frame.contentDocument
|
|
|
+ // 等待图片等子资源就绪,避免截图内容缺失
|
|
|
+ await Promise.all(Array.from(doc.images || []).map(img =>
|
|
|
+ img.complete ? Promise.resolve() : new Promise(resolve => { img.onload = img.onerror = resolve })
|
|
|
+ ))
|
|
|
+ const canvas = await html2canvas(doc.body, {
|
|
|
+ scale: 2,
|
|
|
+ backgroundColor: '#ffffff',
|
|
|
+ useCORS: true,
|
|
|
+ })
|
|
|
+ if (!canvas.width || !canvas.height) throw new Error('导出内容为空')
|
|
|
+
|
|
|
+ // 收集“安全分页点”:文本行底部 / 块级元素底部,分页优先落在此处,避免一行文字被拦腰切成两半
|
|
|
+ // 原子元素(表格行、图片等)内部不允许切割,否则会破坏其视觉结构
|
|
|
+ const bodyRect = doc.body.getBoundingClientRect()
|
|
|
+ const scaleY = canvas.height / bodyRect.height
|
|
|
+ const breakPoints = (() => {
|
|
|
+ const pts = new Set()
|
|
|
+ const atomics = []
|
|
|
+ doc.querySelectorAll('tr,img,svg,video').forEach((el) => {
|
|
|
+ const r = el.getBoundingClientRect()
|
|
|
+ if (r.height > 0) atomics.push(r)
|
|
|
+ })
|
|
|
+ const isSafe = (cssBottom) =>
|
|
|
+ cssBottom > 0 && cssBottom <= bodyRect.height &&
|
|
|
+ !atomics.some((a) => cssBottom > a.top + 1 && cssBottom < a.bottom - 1)
|
|
|
+ doc.body.querySelectorAll('*').forEach((el) => {
|
|
|
+ const r = el.getBoundingClientRect()
|
|
|
+ if (r.height > 0 && isSafe(r.bottom)) pts.add(Math.round((r.bottom - bodyRect.top) * scaleY))
|
|
|
+ })
|
|
|
+ // 长段落/代码块内部按“文本行”断页
|
|
|
+ const walker = doc.createTreeWalker(doc.body, frame.contentWindow.NodeFilter.SHOW_TEXT)
|
|
|
+ const range = doc.createRange()
|
|
|
+ let node
|
|
|
+ while ((node = walker.nextNode())) {
|
|
|
+ range.selectNodeContents(node)
|
|
|
+ Array.from(range.getClientRects()).forEach((r) => {
|
|
|
+ if (r.height > 0 && isSafe(r.bottom)) pts.add(Math.round((r.bottom - bodyRect.top) * scaleY))
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return Array.from(pts).sort((a, b) => a - b)
|
|
|
+ })()
|
|
|
+
|
|
|
+ const pdf = new jsPDF({ unit: 'mm', format: 'a4', compress: true })
|
|
|
+ const MARGIN = 12
|
|
|
+ const contentW = 210 - MARGIN * 2
|
|
|
+ const contentH = 297 - MARGIN * 2
|
|
|
+ const pxPerMm = canvas.width / contentW
|
|
|
+ const pageHpx = Math.max(1, Math.floor(contentH * pxPerMm))
|
|
|
+ const MIN_SLICE = 8 // 每页最小保留高度,防止产生退化切片
|
|
|
+ let offset = 0
|
|
|
+ let pageIndex = 0
|
|
|
+ while (offset < canvas.height) {
|
|
|
+ let end
|
|
|
+ if (canvas.height - offset <= pageHpx + 4) {
|
|
|
+ // 剩余内容不足一页(含舍入残余)时整页放下,避免切出近空白的尾页
|
|
|
+ end = canvas.height
|
|
|
+ } else {
|
|
|
+ // 在 (offset + MIN_SLICE, offset + pageHpx] 内取最大的安全分页点;找不到(如整页大图)退回硬切
|
|
|
+ end = 0
|
|
|
+ for (let i = breakPoints.length - 1; i >= 0; i--) {
|
|
|
+ if (breakPoints[i] <= offset + pageHpx && breakPoints[i] > offset + MIN_SLICE) { end = breakPoints[i]; break }
|
|
|
+ }
|
|
|
+ if (end <= offset) end = offset + pageHpx
|
|
|
+ }
|
|
|
+ const sliceH = end - offset
|
|
|
+ const slice = document.createElement('canvas')
|
|
|
+ slice.width = canvas.width
|
|
|
+ slice.height = sliceH
|
|
|
+ slice.getContext('2d').drawImage(canvas, 0, offset, canvas.width, sliceH, 0, 0, canvas.width, sliceH)
|
|
|
+ if (pageIndex > 0) pdf.addPage()
|
|
|
+ pdf.addImage(slice.toDataURL('image/jpeg', 0.92), 'JPEG', MARGIN, MARGIN, contentW, sliceH / pxPerMm)
|
|
|
+ offset = end
|
|
|
+ pageIndex++
|
|
|
+ }
|
|
|
+ pdf.save(filename)
|
|
|
+ } finally {
|
|
|
+ frame.remove()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
// 数字格式化(保留以备后续接口扩展时使用)
|
|
|
// const formatNumber = (num) => {
|
|
|
// if (num === null || num === undefined) return '0'
|
|
|
@@ -187,6 +435,15 @@ const formatTime = (ts) => {
|
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
|
}
|
|
|
|
|
|
+// 会话列表时间(时间戳(秒)→ YYYY-MM-DD HH:mm)
|
|
|
+const formatConvTime = (ts) => {
|
|
|
+ if (!ts) return ''
|
|
|
+ const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
|
|
|
+ if (Number.isNaN(d.getTime())) return ''
|
|
|
+ const pad = (n) => String(n).padStart(2, '0')
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
|
+}
|
|
|
+
|
|
|
// 友好模式名
|
|
|
const MODE_LABEL = {
|
|
|
workflow: '工作流',
|
|
|
@@ -211,6 +468,18 @@ const getAgentEmptyState = (agent) => {
|
|
|
return matched || agentEmptyStates.default
|
|
|
}
|
|
|
|
|
|
+// 是否为「报告生成」智能体(按名称/别名匹配,与空状态配置同源)
|
|
|
+const isReportAgent = (agent) => {
|
|
|
+ const currentName = normalizeAgentName(agent?.name || agent?.title)
|
|
|
+ if (!currentName) return false
|
|
|
+ const reportCfg = agentEmptyStates.agents.find(item => item.name === '报告生成')
|
|
|
+ const names = [reportCfg?.name, ...(reportCfg?.aliases || [])]
|
|
|
+ return names.some(name => {
|
|
|
+ const normalized = normalizeAgentName(name)
|
|
|
+ return normalized && (currentName.includes(normalized) || normalized.includes(currentName))
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
function AgentExecute() {
|
|
|
const { appId } = useParams()
|
|
|
const location = useLocation()
|
|
|
@@ -257,6 +526,10 @@ function AgentExecute() {
|
|
|
const [historyList, setHistoryList] = useState([])
|
|
|
const [historyLoading, setHistoryLoading] = useState(false)
|
|
|
const [historyError, setHistoryError] = useState('')
|
|
|
+ const [historyHasMore, setHistoryHasMore] = useState(false) // 是否还有下一页
|
|
|
+ const [historyLoadingMore, setHistoryLoadingMore] = useState(false)
|
|
|
+ const historyListRef = useRef([]) // 列表镜像(取 last_id 游标)
|
|
|
+ const historyLoadingRef = useRef(false) // 加载中互斥(防滚动重复触发)
|
|
|
const [resumingConv, setResumingConv] = useState(null)
|
|
|
const [openDownloadId, setOpenDownloadId] = useState(null)
|
|
|
const messagesRef = useRef(null)
|
|
|
@@ -286,6 +559,10 @@ function AgentExecute() {
|
|
|
setHistoryOpen(false)
|
|
|
setHistoryList([])
|
|
|
setHistoryError('')
|
|
|
+ setHistoryHasMore(false)
|
|
|
+ setHistoryLoadingMore(false)
|
|
|
+ historyListRef.current = []
|
|
|
+ historyLoadingRef.current = false
|
|
|
setOpenDownloadId(null)
|
|
|
}, [appId])
|
|
|
|
|
|
@@ -1059,12 +1336,31 @@ function AgentExecute() {
|
|
|
// ===== 聊天模式(占位,保留旧 UI) =====
|
|
|
const [input, setInput] = useState('')
|
|
|
const [messages, setMessages] = useState([])
|
|
|
+ // ===== AI 消息操作:复制 / 听语音 / 点赞点踩 / 编辑 =====
|
|
|
+ const [copiedMsgId, setCopiedMsgId] = useState(null)
|
|
|
+ const [speakingId, setSpeakingId] = useState(null)
|
|
|
+ const [editingMsg, setEditingMsg] = useState(null) // 正在编辑的消息
|
|
|
+ const [editDraft, setEditDraft] = useState('') // 编辑中的草稿
|
|
|
+ const canEditMessage = isReportAgent(agent)
|
|
|
const isChat = mode === 'chat' || mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'agentic' || mode === 'completion' || !mode
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (messagesAutoScrollRef.current) scrollToBottom(messagesRef.current)
|
|
|
}, [messages, chatRunning])
|
|
|
|
|
|
+ // 组件卸载时停止语音朗读
|
|
|
+ useEffect(() => () => {
|
|
|
+ window.speechSynthesis?.cancel()
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ // 正在朗读的消息不再展示(切换历史会话 / 开启新会话清空)时,停止朗读
|
|
|
+ useEffect(() => {
|
|
|
+ if (speakingId && !messages.some(m => m.id === speakingId)) {
|
|
|
+ window.speechSynthesis?.cancel()
|
|
|
+ setSpeakingId(null)
|
|
|
+ }
|
|
|
+ }, [messages, speakingId])
|
|
|
+
|
|
|
// ===== API Key 弹框 =====
|
|
|
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
|
|
const [copied, setCopied] = useState(false)
|
|
|
@@ -1089,29 +1385,60 @@ function AgentExecute() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const fetchConversations = useCallback(async () => {
|
|
|
- setHistoryLoading(true)
|
|
|
- setHistoryError('')
|
|
|
+ // ===== 历史会话:滚动分页加载(Dify /v1/conversations,sort_by=-updated_at 倒序,首屏返回最新 limit 条) =====
|
|
|
+ const HISTORY_PAGE_SIZE = 20
|
|
|
+
|
|
|
+ // reset=true 拉第一页;否则用上一页最后一条 id 作为 last_id 游标追加下一页
|
|
|
+ const fetchConversations = useCallback(async (reset = false) => {
|
|
|
+ if (historyLoadingRef.current) return
|
|
|
+ historyLoadingRef.current = true
|
|
|
+ if (reset) {
|
|
|
+ setHistoryLoading(true)
|
|
|
+ setHistoryError('')
|
|
|
+ } else {
|
|
|
+ setHistoryLoadingMore(true)
|
|
|
+ }
|
|
|
try {
|
|
|
- const res = await getBusinessConversations()
|
|
|
- const list = Array.isArray(res?.data) ? res.data : (Array.isArray(res?.data?.data) ? res.data.data : [])
|
|
|
- setHistoryList(list.filter(conv => !conv.app_id || conv.app_id === appId))
|
|
|
+ const lastId = reset ? '' : (historyListRef.current[historyListRef.current.length - 1]?.id || '')
|
|
|
+ const res = await getConversations(apiKey, { lastId, limit: HISTORY_PAGE_SIZE })
|
|
|
+ const list = Array.isArray(res?.data) ? res.data : []
|
|
|
+ historyListRef.current = reset ? list : [...historyListRef.current, ...list]
|
|
|
+ setHistoryList(historyListRef.current)
|
|
|
+ setHistoryHasMore(!!res?.has_more && list.length > 0)
|
|
|
} catch (e) {
|
|
|
- setHistoryError(e?.message || '加载历史记录失败')
|
|
|
- setHistoryList([])
|
|
|
+ if (reset) {
|
|
|
+ setHistoryError(e?.message || '加载历史记录失败')
|
|
|
+ setHistoryList([])
|
|
|
+ historyListRef.current = []
|
|
|
+ setHistoryHasMore(false)
|
|
|
+ } else {
|
|
|
+ setHistoryError(e?.message || '加载更多失败')
|
|
|
+ }
|
|
|
} finally {
|
|
|
+ historyLoadingRef.current = false
|
|
|
setHistoryLoading(false)
|
|
|
+ setHistoryLoadingMore(false)
|
|
|
}
|
|
|
- }, [appId])
|
|
|
+ }, [apiKey])
|
|
|
|
|
|
const handleToggleHistory = useCallback(() => {
|
|
|
setHistoryOpen(prev => {
|
|
|
const next = !prev
|
|
|
- if (next) fetchConversations()
|
|
|
+ // 每次打开都重新拉第一页(最新 limit 条)
|
|
|
+ if (next) fetchConversations(true)
|
|
|
return next
|
|
|
})
|
|
|
}, [fetchConversations])
|
|
|
|
|
|
+ // 抽屉滚动到底部附近时加载下一页
|
|
|
+ const handleHistoryScroll = useCallback((e) => {
|
|
|
+ if (historyLoading || historyLoadingMore || !historyHasMore) return
|
|
|
+ const el = e.currentTarget
|
|
|
+ if (el.scrollHeight - el.scrollTop - el.clientHeight <= 40) {
|
|
|
+ fetchConversations(false)
|
|
|
+ }
|
|
|
+ }, [historyLoading, historyLoadingMore, historyHasMore, fetchConversations])
|
|
|
+
|
|
|
const handleSelectHistory = useCallback(async (conv) => {
|
|
|
if (!conv?.id || resumingConv) return
|
|
|
if (chatRunning && !viewingHistoryRef.current) {
|
|
|
@@ -1126,9 +1453,10 @@ function AgentExecute() {
|
|
|
}
|
|
|
setResumingConv(conv.id)
|
|
|
try {
|
|
|
- const res = await getBusinessConversationDetail(conv.id)
|
|
|
+ // 恢复会话:直接调 Dify /v1/messages 拉取该会话的历史消息(鉴权用当前智能体 API Key)
|
|
|
+ const res = await getChatMessages(apiKey, conv.id)
|
|
|
const detail = res?.data ?? res ?? {}
|
|
|
- const rows = Array.isArray(detail.messages) ? detail.messages : []
|
|
|
+ const rows = Array.isArray(detail.data) ? detail.data : (Array.isArray(detail) ? detail : [])
|
|
|
const restored = rows
|
|
|
.filter(m => m && (m.query || m.answer))
|
|
|
.sort((a, b) => (a.created_at || 0) - (b.created_at || 0))
|
|
|
@@ -1148,7 +1476,7 @@ function AgentExecute() {
|
|
|
} finally {
|
|
|
setResumingConv(null)
|
|
|
}
|
|
|
- }, [chatConversationId, chatRunError, chatRunning, input, messages, resumingConv])
|
|
|
+ }, [apiKey, chatConversationId, chatRunError, chatRunning, input, messages, resumingConv])
|
|
|
|
|
|
const handleBackToActiveChat = useCallback(() => {
|
|
|
const snapshot = activeChatSnapshotRef.current
|
|
|
@@ -1165,22 +1493,26 @@ function AgentExecute() {
|
|
|
setHasActiveChatSnapshot(false)
|
|
|
}, [])
|
|
|
|
|
|
+ // 报告导出(Word / PDF):只导出正文,剔除思考过程与 <step> 过程提示(与页面展示同源 getAnswerText)
|
|
|
const handleDownloadMessage = useCallback(async (m, format) => {
|
|
|
- if (!m?.messageId || m.downloading) return
|
|
|
+ if (m?.downloading) return
|
|
|
+ const answer = getAnswerText(m)
|
|
|
+ if (!answer) {
|
|
|
+ setChatRunError('暂无可导出的正文内容')
|
|
|
+ return
|
|
|
+ }
|
|
|
setMessages(prev => prev.map(x => x.id === m.id ? { ...x, downloading: format } : x))
|
|
|
try {
|
|
|
- const { blob } = await downloadMessageContent(m.messageId, format)
|
|
|
- const url = URL.createObjectURL(blob)
|
|
|
- const a = document.createElement('a')
|
|
|
- a.href = url
|
|
|
- const ext = format === 'pdf' ? 'pdf' : 'docx'
|
|
|
- a.download = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}.${ext}`
|
|
|
- document.body.appendChild(a)
|
|
|
- a.click()
|
|
|
- document.body.removeChild(a)
|
|
|
- setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
|
+ const filename = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}`
|
|
|
+ if (format === 'word') {
|
|
|
+ // HTML 打包为 altChunk .docx(Word/WPS 可直接打开)
|
|
|
+ downloadBlob(await htmlToDocxBlob(buildExportHtml(renderMarkdown(answer))), `${filename}.docx`)
|
|
|
+ } else {
|
|
|
+ // html2canvas + jsPDF 直接生成 PDF 文件下载
|
|
|
+ await exportHtmlToPdf(buildExportHtml(renderMarkdown(answer)), `${filename}.pdf`)
|
|
|
+ }
|
|
|
} catch (e) {
|
|
|
- setChatRunError(e?.message || '下载失败')
|
|
|
+ setChatRunError(`导出失败:${e?.message || '未知错误'}`)
|
|
|
} finally {
|
|
|
setMessages(prev => prev.map(x => x.id === m.id ? { ...x, downloading: null } : x))
|
|
|
}
|
|
|
@@ -1215,50 +1547,28 @@ function AgentExecute() {
|
|
|
})
|
|
|
}, [chatRunning, handleNewChat])
|
|
|
|
|
|
- const handleSend = async () => {
|
|
|
- const text = input.trim()
|
|
|
- if (!text || chatRunning) return
|
|
|
- const missingFields = getMissingRequiredFields()
|
|
|
- if (missingFields.length > 0) {
|
|
|
- setChatRunError(formatRequiredFieldsError(missingFields))
|
|
|
- setShowChatParamsModal(true)
|
|
|
- return
|
|
|
- }
|
|
|
- if (!apiKey) {
|
|
|
- setChatRunError('缺少 API Key')
|
|
|
- return
|
|
|
- }
|
|
|
+ // 发起一次对话流式请求(发送 & 重新生成共用)
|
|
|
+ const runChatStream = async (query, assistantMsgId) => {
|
|
|
chatAbortRef.current?.abort()
|
|
|
const controller = new AbortController()
|
|
|
chatAbortRef.current = controller
|
|
|
setChatRunError('')
|
|
|
-
|
|
|
- // 1) 推入 user 消息
|
|
|
- const userMsgId = 'u-' + Date.now()
|
|
|
- const assistantMsgId = 'a-' + Date.now()
|
|
|
- messagesAutoScrollRef.current = true
|
|
|
- setMessages(prev => [
|
|
|
- ...prev,
|
|
|
- { id: userMsgId, role: 'user', content: text },
|
|
|
- { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true },
|
|
|
- ])
|
|
|
- setInput('')
|
|
|
setChatStreamIdSafe(assistantMsgId)
|
|
|
setChatRunning(true)
|
|
|
|
|
|
try {
|
|
|
- // 2) 调 /chat-messages 接口(带 inputs + conversation_id 续传)
|
|
|
+ // 调 /chat-messages 接口(带 inputs + conversation_id 续传)
|
|
|
const res = await runChatMessage(
|
|
|
apiKey,
|
|
|
{
|
|
|
- query: text,
|
|
|
+ query,
|
|
|
inputs: await buildInputsWithUploadedPdfs(formValues, controller.signal),
|
|
|
conversation_id: chatConversationIdRef.current || chatConversationId || ''
|
|
|
},
|
|
|
undefined,
|
|
|
{ signal: controller.signal }
|
|
|
)
|
|
|
- // 3) 解析 SSE 流
|
|
|
+ // 解析 SSE 流
|
|
|
await consumeSSE(
|
|
|
res,
|
|
|
handleChatSSEEvent,
|
|
|
@@ -1300,11 +1610,161 @@ function AgentExecute() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ const handleSend = async () => {
|
|
|
+ const text = input.trim()
|
|
|
+ if (!text || chatRunning) return
|
|
|
+ const missingFields = getMissingRequiredFields()
|
|
|
+ if (missingFields.length > 0) {
|
|
|
+ setChatRunError(formatRequiredFieldsError(missingFields))
|
|
|
+ setShowChatParamsModal(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!apiKey) {
|
|
|
+ setChatRunError('缺少 API Key')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1) 推入 user 消息
|
|
|
+ const userMsgId = 'u-' + Date.now()
|
|
|
+ const assistantMsgId = 'a-' + Date.now()
|
|
|
+ messagesAutoScrollRef.current = true
|
|
|
+ setMessages(prev => [
|
|
|
+ ...prev,
|
|
|
+ { id: userMsgId, role: 'user', content: text },
|
|
|
+ { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true },
|
|
|
+ ])
|
|
|
+ setInput('')
|
|
|
+
|
|
|
+ // 2) 发起流式请求
|
|
|
+ await runChatStream(text, assistantMsgId)
|
|
|
+ }
|
|
|
+
|
|
|
// 停止当前对话流
|
|
|
const handleStopChat = () => {
|
|
|
chatAbortRef.current?.abort()
|
|
|
}
|
|
|
|
|
|
+ // ===== AI 消息操作 =====
|
|
|
+ // 复制正文
|
|
|
+ const handleCopyMessage = async (m) => {
|
|
|
+ const text = getAnswerText(m)
|
|
|
+ if (!text) return
|
|
|
+ const ok = await copyTextToClipboard(text)
|
|
|
+ if (!ok) {
|
|
|
+ Modal.warning({ title: '复制失败', content: '当前浏览器不支持自动复制,请手动选择文本复制。' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setCopiedMsgId(m.id)
|
|
|
+ setTimeout(() => setCopiedMsgId(prev => (prev === m.id ? null : prev)), 1500)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 听语音(Web Speech API,可再次点击停止)
|
|
|
+ const handleToggleSpeak = (m) => {
|
|
|
+ const synth = window.speechSynthesis
|
|
|
+ if (!synth) return
|
|
|
+ if (speakingId === m.id) {
|
|
|
+ synth.cancel()
|
|
|
+ setSpeakingId(null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ synth.cancel()
|
|
|
+ const raw = getAnswerText(m)
|
|
|
+ if (!raw) return
|
|
|
+ // 去掉代码块与常见 markdown 符号,提升朗读体验
|
|
|
+ const text = raw
|
|
|
+ .replace(/```[\s\S]*?```/g, ' ')
|
|
|
+ .replace(/[#>*_~[\]()!|-]/g, ' ')
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
+ .trim()
|
|
|
+ if (!text) return
|
|
|
+ const utterance = new SpeechSynthesisUtterance(text)
|
|
|
+ utterance.lang = 'zh-CN'
|
|
|
+ utterance.onend = () => setSpeakingId(prev => (prev === m.id ? null : prev))
|
|
|
+ utterance.onerror = () => setSpeakingId(prev => (prev === m.id ? null : prev))
|
|
|
+ setSpeakingId(m.id)
|
|
|
+ synth.speak(utterance)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点赞 / 点踩(互斥,再次点击取消)
|
|
|
+ const handleFeedback = (m, type) => {
|
|
|
+ setMessages(prev => prev.map(x =>
|
|
|
+ x.id === m.id ? { ...x, feedback: x.feedback === type ? null : type } : x
|
|
|
+ ))
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 编辑 AI 消息(右侧抽屉) =====
|
|
|
+ const handleOpenEditor = (m) => {
|
|
|
+ // 编辑前停止本条语音朗读
|
|
|
+ if (speakingId === m.id) {
|
|
|
+ window.speechSynthesis?.cancel()
|
|
|
+ setSpeakingId(null)
|
|
|
+ }
|
|
|
+ setEditingMsg(m)
|
|
|
+ // 与「复制」按钮同源(getAnswerText):剔除思考过程与 <step> 过程提示后再转 HTML 载入富文本
|
|
|
+ const parsed = splitThinkContent(m.content || '')
|
|
|
+ const answer = parsed.answer || (!parsed.thought ? m.content || '' : '')
|
|
|
+ setEditDraft(renderMarkdown(splitStepContent(answer).text))
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleCloseEditor = () => {
|
|
|
+ setEditingMsg(null)
|
|
|
+ setEditDraft('')
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSaveEditor = () => {
|
|
|
+ if (!editingMsg) return
|
|
|
+ const draft = editDraft
|
|
|
+ setMessages(prev => prev.map(x =>
|
|
|
+ x.id === editingMsg.id
|
|
|
+ ? { ...x, content: draft, thinking: splitThinkContent(draft).thinking, edited: true }
|
|
|
+ : x
|
|
|
+ ))
|
|
|
+ handleCloseEditor()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 编辑内容导出(导出当前编辑草稿,所见即所得) =====
|
|
|
+ const [editorExporting, setEditorExporting] = useState(false)
|
|
|
+
|
|
|
+ const handleExportDocx = async () => {
|
|
|
+ if (!editDraft || editorExporting) return
|
|
|
+ setEditorExporting(true)
|
|
|
+ try {
|
|
|
+ // HTML 打包为 altChunk .docx(Word/WPS 可直接打开)
|
|
|
+ const filename = `${sanitizeDownloadName(agent.name)}-${formatDownloadTimestamp()}.docx`
|
|
|
+ downloadBlob(await htmlToDocxBlob(buildExportHtml(editDraft)), filename)
|
|
|
+ } catch (e) {
|
|
|
+ setChatRunError(`导出失败:${e?.message || '未知错误'}`)
|
|
|
+ } finally {
|
|
|
+ setEditorExporting(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重新生成:用上一条用户提问重新请求
|
|
|
+ const handleRegenerate = async (m) => {
|
|
|
+ if (chatRunning) return
|
|
|
+ const idx = messages.findIndex(x => x.id === m.id)
|
|
|
+ if (idx < 0) return
|
|
|
+ let query = ''
|
|
|
+ for (let j = idx - 1; j >= 0; j--) {
|
|
|
+ if (messages[j].role === 'user') { query = messages[j].content || ''; break }
|
|
|
+ }
|
|
|
+ if (!query) return
|
|
|
+ // 停掉本条正在播放的语音
|
|
|
+ if (speakingId === m.id) {
|
|
|
+ window.speechSynthesis?.cancel()
|
|
|
+ setSpeakingId(null)
|
|
|
+ }
|
|
|
+ const assistantMsgId = 'a-' + Date.now()
|
|
|
+ messagesAutoScrollRef.current = true
|
|
|
+ // 原位替换为新的流式占位消息
|
|
|
+ setMessages(prev => prev.map(x =>
|
|
|
+ x.id === m.id
|
|
|
+ ? { id: assistantMsgId, role: 'assistant', content: STREAM_WAITING_TEXT, streaming: true, pending: true }
|
|
|
+ : x
|
|
|
+ ))
|
|
|
+ await runChatStream(query, assistantMsgId)
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
<div className="agent-execute">
|
|
|
{/* 智能体详情卡片 */}
|
|
|
@@ -1643,9 +2103,7 @@ function AgentExecute() {
|
|
|
<div key={m.id || i} className={`execute-msg execute-msg-${m.role}`}>
|
|
|
<span className="execute-msg-role">{m.role === 'user' ? '我' : agent.name}</span>
|
|
|
<div className="execute-msg-bubble">
|
|
|
- {m.pending ? (
|
|
|
- <span className="execute-msg-generating">{m.content}</span>
|
|
|
- ) : m.role === 'assistant' ? (
|
|
|
+ {m.role === 'assistant' ? (
|
|
|
renderAssistantMessage(m)
|
|
|
) : (
|
|
|
<div
|
|
|
@@ -1653,32 +2111,87 @@ function AgentExecute() {
|
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content) }}
|
|
|
/>
|
|
|
)}
|
|
|
- {m.streaming && <span className="wf-step-cursor">▍</span>}
|
|
|
- </div>
|
|
|
- {!m.streaming && m.role === 'assistant' && m.messageId && m.content && (
|
|
|
+ {m.streaming && !getActiveStepHint(m) && <span className="wf-step-cursor">▍</span>}
|
|
|
+ {!m.streaming && m.role === 'assistant' && m.messageId && m.content && (
|
|
|
<div className="execute-msg-actions">
|
|
|
<button
|
|
|
type="button"
|
|
|
- className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-word"
|
|
|
- disabled={!!m.downloading}
|
|
|
- onClick={() => handleDownloadMessage(m, 'word')}
|
|
|
- title="下载 Word"
|
|
|
+ className={`execute-msg-icon-btn${copiedMsgId === m.id ? ' is-active' : ''}`}
|
|
|
+ data-tip={copiedMsgId === m.id ? '已复制' : '复制'}
|
|
|
+ onClick={() => handleCopyMessage(m)}
|
|
|
+ >
|
|
|
+ <IconCopy />
|
|
|
+ </button>
|
|
|
+ {canEditMessage && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={`execute-msg-icon-btn${m.edited ? ' is-active' : ''}`}
|
|
|
+ data-tip="编辑"
|
|
|
+ onClick={() => handleOpenEditor(m)}
|
|
|
+ >
|
|
|
+ <IconEdit />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={`execute-msg-icon-btn${speakingId === m.id ? ' is-active is-speaking' : ''}`}
|
|
|
+ data-tip={speakingId === m.id ? '停止播放' : '听语音'}
|
|
|
+ onClick={() => handleToggleSpeak(m)}
|
|
|
+ >
|
|
|
+ {speakingId === m.id ? <IconStop /> : <IconVolume />}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={`execute-msg-icon-btn execute-msg-like-btn${m.feedback === 'like' ? ' is-liked' : ''}`}
|
|
|
+ data-tip={m.feedback === 'like' ? '取消点赞' : '点赞'}
|
|
|
+ onClick={() => handleFeedback(m, 'like')}
|
|
|
+ >
|
|
|
+ <IconThumbUp />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={`execute-msg-icon-btn execute-msg-dislike-btn${m.feedback === 'dislike' ? ' is-disliked' : ''}`}
|
|
|
+ data-tip={m.feedback === 'dislike' ? '取消点踩' : '点踩'}
|
|
|
+ onClick={() => handleFeedback(m, 'dislike')}
|
|
|
>
|
|
|
- <img src={wordIcon} alt="" className="execute-msg-download-icon" />
|
|
|
- <span className="execute-msg-download-text">{m.downloading === 'word' ? '生成中...' : 'Word'}</span>
|
|
|
+ <IconThumbDown />
|
|
|
</button>
|
|
|
<button
|
|
|
type="button"
|
|
|
- className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-pdf"
|
|
|
- disabled={!!m.downloading}
|
|
|
- onClick={() => handleDownloadMessage(m, 'pdf')}
|
|
|
- title="下载 PDF"
|
|
|
+ className="execute-msg-icon-btn"
|
|
|
+ data-tip="重新生成"
|
|
|
+ disabled={chatRunning || String(m.id || '').startsWith('history-')}
|
|
|
+ onClick={() => handleRegenerate(m)}
|
|
|
>
|
|
|
- <img src={pdfIcon} alt="" className="execute-msg-download-icon" />
|
|
|
- <span className="execute-msg-download-text">{m.downloading === 'pdf' ? '生成中...' : 'PDF'}</span>
|
|
|
+ <IconRefresh />
|
|
|
</button>
|
|
|
+ {isReportAgent(agent) && (
|
|
|
+ <div className="execute-msg-actions-right">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-word"
|
|
|
+ disabled={!!m.downloading}
|
|
|
+ onClick={() => handleDownloadMessage(m, 'word')}
|
|
|
+ title="下载 Word"
|
|
|
+ >
|
|
|
+ <img src={wordIcon} alt="" className="execute-msg-download-icon" />
|
|
|
+ <span className="execute-msg-download-text">{m.downloading === 'word' ? '生成中...' : 'Word'}</span>
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="execute-msg-action-btn execute-msg-download-btn execute-msg-download-pdf"
|
|
|
+ disabled={!!m.downloading}
|
|
|
+ onClick={() => handleDownloadMessage(m, 'pdf')}
|
|
|
+ title="下载 PDF"
|
|
|
+ >
|
|
|
+ <img src={pdfIcon} alt="" className="execute-msg-download-icon" />
|
|
|
+ <span className="execute-msg-download-text">{m.downloading === 'pdf' ? '生成中...' : 'PDF'}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
))
|
|
|
)}
|
|
|
@@ -1697,7 +2210,7 @@ function AgentExecute() {
|
|
|
handleSend()
|
|
|
}
|
|
|
}}
|
|
|
- placeholder="请描述报告要求,例如:总结研究背景、核心结论和风险建议,输出为正式汇报口吻..."
|
|
|
+ placeholder="请输入你的问题或任务,可补充背景信息和期望的输出要求,我会结合当前智能体能力为你解答..."
|
|
|
disabled={chatRunning}
|
|
|
rows={2}
|
|
|
/>
|
|
|
@@ -1837,7 +2350,7 @@ function AgentExecute() {
|
|
|
<button type="button" className="execute-drawer-close" onClick={() => setHistoryOpen(false)} aria-label="关闭">x</button>
|
|
|
<div className="execute-drawer-title">历史记录</div>
|
|
|
</div>
|
|
|
- <div className="execute-drawer-body">
|
|
|
+ <div className="execute-drawer-body" onScroll={handleHistoryScroll}>
|
|
|
{historyLoading ? (
|
|
|
<div className="execute-drawer-status">加载中...</div>
|
|
|
) : historyError ? (
|
|
|
@@ -1845,22 +2358,57 @@ function AgentExecute() {
|
|
|
) : historyList.length === 0 ? (
|
|
|
<div className="execute-drawer-status">暂无历史记录</div>
|
|
|
) : (
|
|
|
- <ul className="execute-drawer-list">
|
|
|
- {historyList.map(conv => (
|
|
|
- <li key={conv.id} className={`execute-drawer-item ${resumingConv === conv.id ? 'is-loading' : ''}`} onClick={() => handleSelectHistory(conv)}>
|
|
|
- <div className="execute-drawer-item-main">
|
|
|
- <div className="execute-drawer-item-name">{conv.name || conv.summary || '新会话'}</div>
|
|
|
- <div className="execute-drawer-item-meta">{conv.message_count || 0} 条消息</div>
|
|
|
- </div>
|
|
|
- </li>
|
|
|
- ))}
|
|
|
- </ul>
|
|
|
+ <>
|
|
|
+ <ul className="execute-drawer-list">
|
|
|
+ {historyList.map(conv => (
|
|
|
+ <li key={conv.id} className={`execute-drawer-item ${resumingConv === conv.id ? 'is-loading' : ''}`} onClick={() => handleSelectHistory(conv)}>
|
|
|
+ <div className="execute-drawer-item-main">
|
|
|
+ <div className="execute-drawer-item-name">{conv.name || conv.summary || '新会话'}</div>
|
|
|
+ <div className="execute-drawer-item-meta">{formatConvTime(conv.updated_at)}</div>
|
|
|
+ </div>
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ {historyLoadingMore && <div className="execute-drawer-status">加载中...</div>}
|
|
|
+ {!historyLoadingMore && !historyHasMore && <div className="execute-drawer-status">没有更多了</div>}
|
|
|
+ </>
|
|
|
)}
|
|
|
</div>
|
|
|
</aside>
|
|
|
</>
|
|
|
)}
|
|
|
|
|
|
+ {editingMsg && (
|
|
|
+ <>
|
|
|
+ <div className="execute-drawer-mask is-open" onClick={handleCloseEditor} />
|
|
|
+ <aside className="execute-drawer is-open execute-editor-drawer" role="dialog" aria-label="编辑内容">
|
|
|
+ <div className="execute-drawer-head">
|
|
|
+ <button type="button" className="execute-drawer-close" onClick={handleCloseEditor} aria-label="关闭">x</button>
|
|
|
+ <div className="execute-drawer-title">编辑内容</div>
|
|
|
+ </div>
|
|
|
+ <div className="execute-drawer-body execute-editor-body">
|
|
|
+ <RichTextEditor
|
|
|
+ value={editDraft}
|
|
|
+ onChange={setEditDraft}
|
|
|
+ placeholder="请编辑报告内容,支持富文本格式..."
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="execute-editor-foot">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="execute-editor-export"
|
|
|
+ disabled={!editDraft || editorExporting}
|
|
|
+ onClick={handleExportDocx}
|
|
|
+ >
|
|
|
+ {editorExporting ? '导出中…' : '导出 DOCX'}
|
|
|
+ </button>
|
|
|
+ <button type="button" className="execute-editor-cancel" onClick={handleCloseEditor}>取消</button>
|
|
|
+ <button type="button" className="execute-editor-save" onClick={handleSaveEditor}>保存</button>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
</div>
|
|
|
)
|
|
|
}
|