diff --git a/api/app/controllers/plagiarismCheck.js b/api/app/controllers/plagiarismCheck.js index c6cb01e..50c8c3b 100644 --- a/api/app/controllers/plagiarismCheck.js +++ b/api/app/controllers/plagiarismCheck.js @@ -1,6 +1,7 @@ 'use strict'; const plagiarismCheckService = require('../utils/plagiarismCheckService'); +const JSZip = require('jszip'); const { recordAiQueryLog } = require('../utils/aiUsage'); const toInt = (value, defaultValue = null) => { @@ -199,10 +200,23 @@ module.exports.exportTaskFileDocx = async (ctx) => { taskId, fileId, }); + const buffer = Buffer.isBuffer(result.buffer) ? result.buffer : Buffer.from(result.buffer || []); + const signature = buffer.subarray(0, 8).toString('hex'); + ctx.logger?.info?.(`[plagiarism] export response task=${taskId} file=${fileId} size=${buffer.length} head=${signature}`); + if (!(buffer[0] === 0x50 && buffer[1] === 0x4b && [0x03, 0x05, 0x07].includes(buffer[2]))) { + throw new Error(`导出结果不是有效 DOCX ZIP: ${signature}`); + } + const zip = await JSZip.loadAsync(buffer); + if (!zip.file('word/document.xml')) { + throw new Error('导出结果缺少 word/document.xml'); + } ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(result.fileName)}"`); - ctx.body = result.buffer; + ctx.set('Content-Length', String(buffer.length)); ctx.status = 200; + ctx.respond = false; + ctx.res.end(buffer); + return; } catch (error) { ctx.logger?.log?.(error); ctx.status = 400; diff --git a/api/app/controllers/v2Tender.js b/api/app/controllers/v2Tender.js index 3db5c93..5896f48 100644 --- a/api/app/controllers/v2Tender.js +++ b/api/app/controllers/v2Tender.js @@ -33,6 +33,28 @@ const trackGenerateUsage = (ctx, payload) => sharedTrackGenerateUsage(ctx, { analyticsAppKey: ctx.app.fs.config.fastGpt?.v2TenderAppKey, }); +// FastGPT 多模态模型可能返回数组形式的 content(text/image_url 等多部分内容), +// 统一归一化为纯文本字符串,与 report/ai.js 保持一致 +const normalizeFastGptMessageContent = (content) => { + if (typeof content === 'string') return content.trim(); + if (Array.isArray(content)) { + return content + .map(item => { + if (typeof item === 'string') return item; + if (typeof item?.text === 'string') return item.text; + if (typeof item?.text?.content === 'string') return item.text.content; + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); + } + if (content && typeof content === 'object' && typeof content.text === 'string') { + return content.text.trim(); + } + return ''; +}; + const resolveCurrentRegisterSource = async (ctx, preferredUserId = null) => { ctx.fs = ctx.fs || {}; if (ctx.fs.currentRegisterSource !== undefined) { @@ -128,7 +150,7 @@ module.exports.extractTitle = async (ctx, next) => { }) ctx.body = { - title: res.body.choices[0].message.content + title: normalizeFastGptMessageContent(res.body.choices[0].message.content) }; ctx.status = 200; } catch (error) { @@ -251,7 +273,7 @@ module.exports.createTender = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) - const resContent = res.body.choices[0].message.content; + const resContent = normalizeFastGptMessageContent(res.body.choices[0].message.content); // 解析 AI 返回的评分标准内容 const fullMatch = resContent.match(/<完整评分项>([\s\S]*?)<\/完整评分项>/); @@ -472,7 +494,7 @@ module.exports.generateChapters = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) - const resContent = res.body.choices[0].message.content; + const resContent = normalizeFastGptMessageContent(res.body.choices[0].message.content); const previewChapters = JSON.parse(resContent); await models.V2Tenders.update( @@ -536,7 +558,7 @@ module.exports.sortChapters = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }) - const resContent = res.body.choices[0].message.content; + const resContent = normalizeFastGptMessageContent(res.body.choices[0].message.content); const newPreviewChapters = JSON.parse(resContent); await models.V2Tenders.update( @@ -1143,7 +1165,8 @@ const generateAllContent = async (ctx, tender, allChapters, options = {}) => { "Content-Type": "application/json", }); - const content = res.body.choices[0].message.content; + const content = normalizeFastGptMessageContent(res.body.choices[0].message.content); + if (!content) throw '章节生成返回内容为空'; lastContent = content; // 检查该章节是否已有版本记录 @@ -1387,7 +1410,8 @@ module.exports.reWriteChapterContent = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); - const content = res.body.choices[0].message.content; + const content = normalizeFastGptMessageContent(res.body.choices[0].message.content); + if (!content) throw '章节生成返回内容为空'; enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, @@ -1442,7 +1466,7 @@ module.exports.reWriteChapterContent = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); - const splitContent = JSON.parse(splitRes.body.choices[0].message.content); + const splitContent = JSON.parse(normalizeFastGptMessageContent(splitRes.body.choices[0].message.content)); enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, @@ -1531,7 +1555,8 @@ module.exports.reWriteChapterContent = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); - const content = res.body.choices[0].message.content; + const content = normalizeFastGptMessageContent(res.body.choices[0].message.content); + if (!content) throw '章节生成返回内容为空'; lastContent = content; enqueueGenerateUsage({ creator: tender?.creator, @@ -1570,7 +1595,7 @@ module.exports.reWriteChapterContent = async (ctx, next) => { Authorization: `Bearer ${v2TenderAppKey}`, "Content-Type": "application/json", }); - const splitContent = JSON.parse(splitRes.body.choices[0].message.content); + const splitContent = JSON.parse(normalizeFastGptMessageContent(splitRes.body.choices[0].message.content)); enqueueGenerateUsage({ creator: tender?.creator, docId: tender?.id, @@ -1700,7 +1725,8 @@ module.exports.reWriteChapterContent = async (ctx, next) => { "Content-Type": "application/json", }); - const content = res.body.choices[0].message.content; + const content = normalizeFastGptMessageContent(res.body.choices[0].message.content); + if (!content) throw '章节生成返回内容为空'; lastContent = content; enqueueGenerateUsage({ creator: tender?.creator, diff --git a/api/app/utils/docxXmlExporter.js b/api/app/utils/docxXmlExporter.js new file mode 100644 index 0000000..7e9c121 --- /dev/null +++ b/api/app/utils/docxXmlExporter.js @@ -0,0 +1,488 @@ +/** + * 原始DOCX XML导出层 + * 只替换已有段落中的文字并重新打包 + */ +'use strict'; + +const JSZip = require('jszip'); +const cheerio = require('cheerio'); +const imageSize = require('image-size'); + +const XML_PATH = 'word/document.xml'; +const RELS_PATH = 'word/_rels/document.xml.rels'; +const CONTENT_TYPES_PATH = '[Content_Types].xml'; + +const escapeXml = value => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const getBlockText = block => String(block?.plainText || '') + .replace(/\r/g, '') + .replace(/\n+/g, ' ') + .trim(); + +const normalizeXmlText = value => String(value || '') + .replace(/(?:<\/w:tab>)?/g, ' ') + .replace(/(?:<\/w:br>)?/g, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/\s+/g, '') + .trim(); + +const getParagraphs = documentXml => { + const paragraphs = []; + documentXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { + paragraphs.push({ xml: paragraphXml, text: normalizeXmlText(paragraphXml) }); + return paragraphXml; + }); + return paragraphs; +}; + +const stripHtml = value => String(value || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); + +const getTableText = tableXml => normalizeXmlText(tableXml); + +const getHtmlTableCells = html => { + const $ = cheerio.load(String(html || ''), { decodeEntities: false }); + return $('tr').toArray().flatMap(row => $(row).find('th,td').toArray().map(cell => { + const cellHtml = $(cell).html() || ''; + return { html: cellHtml, text: stripHtml(cellHtml) }; + })); +}; + +const getOriginalTableCells = block => (block?.structureInfo?.rows || []) + .flatMap(row => row || []) + .map(cell => String(cell?.text || '').trim()); + +const getXmlTableCells = tableXml => { + const cells = []; + tableXml.replace(/]*)?>[\s\S]*?<\/w:tc>/g, cellXml => { + cells.push(cellXml); + return cellXml; + }); + return cells; +}; + +const getImageDataFromBlock = block => { + const html = String(block?.htmlContent || ''); + const match = html.match(/data:image\/(png|jpeg|jpg|gif|bmp);base64,([A-Za-z0-9+/=\s]+)/i); + if (!match) return null; + const extension = match[1].toLowerCase() === 'jpg' ? 'jpeg' : match[1].toLowerCase(); + return { + extension, + mimeType: `image/${extension}`, + buffer: Buffer.from(match[2].replace(/\s+/g, ''), 'base64'), + }; +}; + +const getNextRelationshipId = relationships => { + const ids = [...relationships.matchAll(/Id="rId(\d+)"/g)].map(match => Number(match[1])); + return `rId${Math.max(0, ...ids) + 1}`; +}; + +const getNextMediaIndex = zip => { + const indexes = Object.keys(zip.files) + .map(name => name.match(/^word\/media\/image(\d+)\.[^/]+$/i)) + .filter(Boolean) + .map(match => Number(match[1])); + return Math.max(0, ...indexes) + 1; +}; + +const buildImageDrawingXml = ({ relationshipId, width, height }) => { + const cx = Math.max(1, Math.round(width * 9525)); + const cy = Math.max(1, Math.round(height * 9525)); + return ``; +}; + +const appendImageRelationship = (relationships, relationshipId, target) => { + const relation = ``; + if (relationships.includes('')) { + return relationships.replace('', `${relation}`); + } + return `${relation}`; +}; + +const registerImageContentType = (contentTypes, extension, mimeType) => { + if (new RegExp(`]+Extension="${extension}"`, 'i').test(contentTypes)) return contentTypes; + const node = ``; + return contentTypes.replace('', `${node}`); +}; + +const buildBlockXmlMapping = async ({ sourceBuffer, blocks }) => { + const zip = await JSZip.loadAsync(sourceBuffer); + const documentFile = zip.file(XML_PATH); + if (!documentFile) throw new Error('DOCX 缺少 word/document.xml'); + const documentXml = await documentFile.async('string'); + const paragraphs = getParagraphs(documentXml); + const tables = []; + documentXml.replace(/]*)?>[\s\S]*?<\/w:tbl>/g, tableXml => { + tables.push(tableXml); + return tableXml; + }); + let cursor = 0; + let tableCursor = 0; + return blocks.map(block => { + if (String(block?.blockType || '').toLowerCase() === 'table') { + const sourceText = normalizeXmlText(getBlockText(block)); + const tableIndex = tables.findIndex((tableXml, index) => index >= tableCursor && getTableText(tableXml).includes(sourceText)); + if (tableIndex >= 0) { + tableCursor = tableIndex + 1; + return { + ...block, + structureInfo: { ...(block.structureInfo || {}), xmlTableIndex: tableIndex }, + }; + } + } + const sourceText = normalizeXmlText(getBlockText(block)); + if (!sourceText) return block; + let foundIndex = -1; + for (let index = cursor; index < paragraphs.length; index += 1) { + const xmlText = paragraphs[index].text; + if (xmlText && (xmlText === sourceText || xmlText.includes(sourceText) || sourceText.includes(xmlText))) { + foundIndex = index; + break; + } + } + if (foundIndex < 0) return block; + cursor = foundIndex + 1; + return { + ...block, + structureInfo: { + ...(block.structureInfo || {}), + xmlParagraphIndex: foundIndex, + }, + }; + }); +}; + +/** + * 编辑/删除后 blockIndex 可能已经重排,不能用它推断原 DOCX 段落位置。 + * 用编辑前保存的原文校验旧映射,失效时按原文顺序重新定位,避免导出静默回退为原文件。 + */ +const repairParagraphMappings = (documentXml, blocks) => { + const paragraphs = getParagraphs(documentXml); + let cursor = 0; + return blocks.map(block => { + if (block?.structureInfo?.xmlTableIndex !== undefined) return block; + const structureInfo = block?.structureInfo && typeof block.structureInfo === 'object' + ? { ...block.structureInfo } + : {}; + const anchorText = normalizeXmlText( + structureInfo.originalPlainText || block?.plainText || '' + ); + if (!anchorText) return block; + + const currentIndex = Number(structureInfo.xmlParagraphIndex); + const currentParagraph = Number.isInteger(currentIndex) ? paragraphs[currentIndex] : null; + const currentMatches = currentParagraph?.text && ( + currentParagraph.text === anchorText || + currentParagraph.text.includes(anchorText) || + anchorText.includes(currentParagraph.text) + ); + if (currentMatches) { + cursor = Math.max(cursor, currentIndex + 1); + return block; + } + + const foundIndex = paragraphs.findIndex((paragraph, index) => index >= cursor && ( + paragraph.text === anchorText || + paragraph.text.includes(anchorText) || + anchorText.includes(paragraph.text) + )); + if (foundIndex < 0) return block; + cursor = foundIndex + 1; + return { + ...block, + structureInfo: { ...structureInfo, xmlParagraphIndex: foundIndex }, + }; + }); +}; + +const getRunText = runXml => normalizeXmlText(runXml); + +const normalizeColor = value => { + const color = String(value || '').trim().replace(/^#/, ''); + if (/^[0-9a-f]{6}$/i.test(color)) return color.toUpperCase(); + if (/^[0-9a-f]{3}$/i.test(color)) return color.split('').map(char => char + char).join('').toUpperCase(); + return ''; +}; + +const parseInlineStyle = styleText => { + const style = {}; + String(styleText || '').split(';').forEach(item => { + const separator = item.indexOf(':'); + if (separator < 0) return; + const name = item.slice(0, separator).trim().toLowerCase(); + const value = item.slice(separator + 1).trim(); + if (name) style[name] = value; + }); + return style; +}; + +const buildRunProperties = marks => { + const properties = []; + if (marks.bold) properties.push(''); + if (marks.italic) properties.push(''); + if (marks.underline) properties.push(''); + if (marks.strike) properties.push(''); + if (marks.color) properties.push(``); + if (marks.fontFamily) { + const font = escapeXml(marks.fontFamily.replace(/["']/g, '').split(',')[0].trim()); + if (font) properties.push(``); + } + if (marks.fontSize) { + const match = String(marks.fontSize).match(/([0-9]+(?:\.[0-9]+)?)\s*(px|pt)?/i); + if (match) { + const points = match[2]?.toLowerCase() === 'px' ? Number(match[1]) * 0.75 : Number(match[1]); + if (Number.isFinite(points) && points > 0) { + const halfPoints = Math.round(points * 2); + properties.push(``); + } + } + } + return properties.length ? `${properties.join('')}` : ''; +}; + +const collectHtmlRuns = html => { + const $ = cheerio.load(String(html || ''), { decodeEntities: false }, false); + const runs = []; + const visit = (node, inheritedMarks = {}) => { + if (node.type === 'text') { + if (node.data) runs.push({ text: node.data, marks: inheritedMarks }); + return; + } + if (node.type !== 'tag' && node.type !== 'root') return; + const tag = String(node.name || '').toLowerCase(); + const attributes = node.attribs || {}; + const style = parseInlineStyle(attributes.style); + const marks = { + ...inheritedMarks, + bold: inheritedMarks.bold || tag === 'strong' || tag === 'b' || /bold|[5-9]00/i.test(style['font-weight'] || ''), + italic: inheritedMarks.italic || tag === 'em' || tag === 'i' || /italic|oblique/i.test(style['font-style'] || ''), + underline: inheritedMarks.underline || tag === 'u' || /underline/i.test(style['text-decoration'] || ''), + strike: inheritedMarks.strike || tag === 's' || tag === 'del' || /line-through/i.test(style['text-decoration'] || ''), + color: normalizeColor(style.color || attributes.color) || inheritedMarks.color || '', + fontFamily: style['font-family'] || inheritedMarks.fontFamily || '', + fontSize: style['font-size'] || inheritedMarks.fontSize || '', + }; + if (tag === 'br') { + runs.push({ text: '\n', marks: inheritedMarks }); + return; + } + (node.children || []).forEach(child => visit(child, marks)); + }; + const root = $.root().get(0); + (root?.children || []).forEach(node => visit(node)); + return runs; +}; + +const hasTiptapInlineFormatting = html => /<(strong|b|em|i|u|s|del|span|br)\b/i.test(String(html || '')); + +const replaceParagraphWithHtmlRuns = (paragraphXml, html) => { + const runs = collectHtmlRuns(html).filter(run => run.text.length > 0); + if (!runs.length) return paragraphXml; + const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; + const runXml = runs.map(run => { + const textParts = String(run.text).replace(/\r\n?/g, '\n').split('\n'); + const content = textParts.map((part, index) => { + const line = part ? `${escapeXml(part)}` : ''; + return index === 0 ? line : `${line}`; + }).join(''); + return `${buildRunProperties(run.marks)}${content}`; + }).join(''); + return `${paragraphProperties}${runXml}`; +}; + +/** + * 按原 run 的文字长度分配新文本 + * 无新增 Tiptap mark 时保留原 run 属性,避免普通编辑丢失原文格式 + */ +const replaceTextNodes = (paragraphXml, text) => { + const runs = []; + paragraphXml.replace(/]*)?>[\s\S]*?<\/w:r>/g, runXml => { + runs.push({ runXml, length: getRunText(runXml).length }); + return runXml; + }); + if (!runs.length) return paragraphXml; + + const totalOriginalLength = runs.reduce((sum, run) => sum + run.length, 0); + const nextText = String(text || ''); + let offset = 0; + const chunks = runs.map((run, index) => { + if (index === runs.length - 1) return nextText.slice(offset); + const share = totalOriginalLength > 0 + ? Math.round(nextText.length * run.length / totalOriginalLength) + : (index === 0 ? nextText.length : 0); + const chunk = nextText.slice(offset, offset + share); + offset += share; + return chunk; + }); + + let runCursor = 0; + return paragraphXml.replace(/]*)?>[\s\S]*?<\/w:r>/g, runXml => { + const index = runCursor++; + const chunk = chunks[index] || ''; + const withoutText = runXml.replace(/]*)?>[\s\S]*?<\/w:t>/g, ''); + if (!chunk) return withoutText; + const textNode = `${escapeXml(chunk)}`; + const runEnd = withoutText.lastIndexOf(''); + return `${withoutText.slice(0, runEnd)}${textNode}${withoutText.slice(runEnd)}`; + }); +}; + +const replaceParagraphText = (paragraphXml, block) => { + if (hasTiptapInlineFormatting(block?.htmlContent)) { + return replaceParagraphWithHtmlRuns(paragraphXml, block.htmlContent); + } + const text = getBlockText(block); + if (!text) return paragraphXml; + return replaceTextNodes(paragraphXml, text); +}; + +const replaceTableText = (tableXml, block) => { + const cellTexts = getHtmlTableCells(block?.htmlContent); + if (!cellTexts.length) return tableXml; + const originalCells = getOriginalTableCells(block); + const xmlCells = getXmlTableCells(tableXml); + const replacements = new Map(); + let searchStart = 0; + + cellTexts.forEach((cell, htmlIndex) => { + const currentText = String(cell.text || '').trim(); + const originalText = String(originalCells[htmlIndex] || '').trim(); + // 未变化的单元格不重建,保留原有 run、换行和单元格内部结构 + if (!currentText || currentText === originalText) return; + + let targetIndex = -1; + if (!originalCells.length) { + // 兼容早期任务没有保存 structureInfo.rows 的数据 + targetIndex = htmlIndex < xmlCells.length ? htmlIndex : -1; + } else if (originalText) { + targetIndex = xmlCells.findIndex((xmlCell, index) => index >= searchStart + && normalizeXmlText(xmlCell) === normalizeXmlText(originalText)); + } else if (htmlIndex < xmlCells.length && !normalizeXmlText(xmlCells[htmlIndex])) { + // 空单元格没有文字锚点,只允许写入同位置的 XML 空单元格 + targetIndex = htmlIndex; + } + if (targetIndex >= 0) { + replacements.set(targetIndex, cell); + searchStart = targetIndex + 1; + } + }); + + if (!replacements.size) return tableXml; + let xmlCellCursor = 0; + return tableXml.replace(/]*)?>[\s\S]*?<\/w:tc>/g, cellXml => { + const targetIndex = xmlCellCursor++; + const cell = replacements.get(targetIndex); + if (!cell) return cellXml; + const cellText = cell.text; + const paragraphs = cellXml.match(/]*)?>[\s\S]*?<\/w:p>/g) || []; + if (!paragraphs.length) return cellXml; + const lines = String(cellText).split(/\r?\n/); + let paragraphCursor = 0; + return cellXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { + const line = lines[paragraphCursor] === undefined + ? '' + : lines[paragraphCursor]; + paragraphCursor += 1; + if (hasTiptapInlineFormatting(cell.html)) { + return replaceParagraphWithHtmlRuns(paragraphXml, cell.html); + } + return replaceTextNodes(paragraphXml, line); + }); + }); +}; + +const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], imageWriter = null) => { + const editableBlocks = blocks + .filter(block => ['paragraph', 'heading', 'table'].includes(String(block?.blockType || '').toLowerCase())) + .sort((a, b) => Number(a.blockIndex || 0) - Number(b.blockIndex || 0)); + const deleted = new Set(deletedParagraphIndexes.map(Number)); + const tableBlocks = editableBlocks.filter(block => block?.structureInfo?.xmlTableIndex !== undefined); + let tableIndex = 0; + let nextXml = documentXml.replace(/]*)?>[\s\S]*?<\/w:tbl>/g, tableXml => { + const block = tableBlocks.find(item => Number(item.structureInfo.xmlTableIndex) === tableIndex); + tableIndex += 1; + return block ? replaceTableText(tableXml, block) : tableXml; + }); + let paragraphIndex = 0; + return nextXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { + const currentIndex = paragraphIndex; + paragraphIndex += 1; + if (deleted.has(currentIndex)) return ''; + const block = editableBlocks.find(item => Number(item?.structureInfo?.xmlParagraphIndex) === currentIndex); + if (!block) return paragraphXml; + if (imageWriter) { + const image = getImageDataFromBlock(block); + if (image) return imageWriter(paragraphXml, image); + } + return replaceParagraphText(paragraphXml, block); + }); +}; + +/** + * 使用原始 DOCX 作为模板回写 block 文本 + * 映射按解析块顺序进行,确保不编辑时原文件可以原样重新打包 + */ +const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIndexes = [] }) => { + const zip = await JSZip.loadAsync(sourceBuffer); + const documentFile = zip.file(XML_PATH); + if (!documentFile) throw new Error('DOCX 缺少 word/document.xml'); + const documentXml = await documentFile.async('string'); + const mappedBlocks = repairParagraphMappings(documentXml, blocks); + const hasXmlMapping = mappedBlocks.some(block => + block?.structureInfo?.xmlParagraphIndex !== undefined || + block?.structureInfo?.xmlTableIndex !== undefined + ); + // 历史任务没有 XML 映射,原样返回可避免错误按顺序改写导致正文丢失。 + if (!hasXmlMapping && !deletedParagraphIndexes.length) return sourceBuffer; + const imageBlocks = mappedBlocks.filter(block => getImageDataFromBlock(block)); + let relationships = zip.file(RELS_PATH) + ? await zip.file(RELS_PATH).async('string') + : ''; + let contentTypes = zip.file(CONTENT_TYPES_PATH) + ? await zip.file(CONTENT_TYPES_PATH).async('string') + : ''; + let mediaIndex = getNextMediaIndex(zip); + const imageWriter = imageBlocks.length ? (paragraphXml, image) => { + const relationshipId = getNextRelationshipId(relationships); + const mediaName = `image${mediaIndex}.${image.extension}`; + mediaIndex += 1; + zip.file(`word/media/${mediaName}`, image.buffer); + relationships = appendImageRelationship(relationships, relationshipId, `media/${mediaName}`); + contentTypes = registerImageContentType(contentTypes, image.extension, image.mimeType); + let width = 600; + let height = 400; + try { + const dimensions = imageSize.imageSize(image.buffer); + width = Math.min(600, dimensions.width || width); + height = Math.max(1, width * (dimensions.height || height) / (dimensions.width || width)); + } catch (error) { + // 图片尺寸损坏时使用稳定兜底尺寸,避免导出整体失败 + } + const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; + return `${paragraphProperties}${buildImageDrawingXml({ relationshipId, width, height })}`; + } : null; + const nextXml = updateDocumentXml(documentXml, mappedBlocks, deletedParagraphIndexes, imageWriter); + const hasVisibleText = /]*)?>[^<]+<\/w:t>/.test(nextXml); + if (!hasVisibleText && !imageBlocks.length) return sourceBuffer; + zip.file(XML_PATH, nextXml); + if (imageBlocks.length) { + zip.file(RELS_PATH, relationships); + zip.file(CONTENT_TYPES_PATH, contentTypes); + } + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); +}; + +module.exports = { + buildBlockXmlMapping, + exportDocxFromOriginal, +}; diff --git a/api/app/utils/plagiarismCheckService.js b/api/app/utils/plagiarismCheckService.js index 12294fa..4ec4a8f 100644 --- a/api/app/utils/plagiarismCheckService.js +++ b/api/app/utils/plagiarismCheckService.js @@ -12,6 +12,7 @@ const mammoth = require('mammoth'); const cheerio = require('cheerio'); const iconv = require('iconv-lite'); const { convertHTMLToDOCX } = require('./tools'); +const { buildBlockXmlMapping, exportDocxFromOriginal } = require('./docxXmlExporter'); const { reportBusinessCall, reportFastgptResponse } = require('../services/dashboardReporter'); const MAX_FILE_COUNT = 5; @@ -63,6 +64,16 @@ const toBuffer = (value) => { return Buffer.from(value); }; +const getBufferSignature = buffer => { + const value = toBuffer(buffer); + return { + size: value.length, + head: value.subarray(0, 8).toString('hex'), + isZip: isDocxZipBuffer(value), + isEncrypted: isEncryptedBuffer(value), + }; +}; + const normalizeExt = (fileName = '') => { const ext = path.extname(String(fileName || '')).replace(/^\./, '').toLowerCase(); return ext; @@ -302,10 +313,12 @@ const validateMagicByExt = (buffer, ext, fileName) => { } }; -const decryptIfNeeded = async (ctx, buffer, fileName) => { - if (!isEncryptedBuffer(buffer)) { - return { buffer, decrypted: false }; - } +const isDocxZipBuffer = buffer => Buffer.isBuffer(buffer) && buffer.length >= 4 + && buffer[0] === 0x50 + && buffer[1] === 0x4b + && [0x03, 0x05, 0x07].includes(buffer[2]); + +const decryptBuffer = async (ctx, buffer, fileName) => { const host = String(ctx.config?.xunruan?.host || ctx.app?.fs?.config?.xunruan?.host || '').replace(/\/+$/, ''); if (!host) throw new Error(`文件 ${fileName} 已加密,但未配置迅软解密服务`); const res = await superagent @@ -316,7 +329,14 @@ const decryptIfNeeded = async (ctx, buffer, fileName) => { .timeout({ response: 30000, deadline: 120000 }); const decrypted = toBuffer(res.body); if (!decrypted.length) throw new Error(`文件 ${fileName} 解密结果为空`); - return { buffer: decrypted, decrypted: true }; + return decrypted; +}; + +const decryptIfNeeded = async (ctx, buffer, fileName) => { + if (!isEncryptedBuffer(buffer)) { + return { buffer, decrypted: false }; + } + return { buffer: await decryptBuffer(ctx, buffer, fileName), decrypted: true }; }; const ensureQiniuConfig = (ctx) => { @@ -625,6 +645,40 @@ const htmlToPlainText = (html = '') => { return text; }; +/** + * 将返回的单段文本写回原HTML的文字节点,保留原有 inline 标签 + * AI 接口只返回文字,因此这里不重建段落,也不改变表格或块结构。 + */ +const replaceHtmlTextPreservingMarks = (html, nextText) => { + const $ = cheerio.load(String(html || ''), { decodeEntities: false }, false); + const textNodes = []; + const visit = node => { + if (node.type === 'text') { + if (node.data) textNodes.push(node); + return; + } + (node.children || []).forEach(visit); + }; + visit($.root().get(0)); + if (!textNodes.length) return html; + + const originalLength = textNodes.reduce((sum, node) => sum + String(node.data || '').length, 0); + const replacement = String(nextText || ''); + let offset = 0; + textNodes.forEach((node, index) => { + const remainingNodes = textNodes.length - index - 1; + const remainingText = replacement.length - offset; + const chunkLength = index === textNodes.length - 1 + ? remainingText + : originalLength > 0 + ? Math.max(0, Math.round(replacement.length * String(node.data || '').length / originalLength)) + : Math.max(0, remainingText - remainingNodes); + node.data = replacement.slice(offset, offset + chunkLength); + offset += chunkLength; + }); + return $.root().html() || `

${escapeHtml(replacement)}

`; +}; + const blocksFromPlainText = (text, source = 'text') => { const normalized = normalizePlainText(text); if (!normalized) return []; @@ -861,9 +915,20 @@ const callDeDuplicationFastGpt = async (ctx, { text, prompt = '' }) => { if (!normalizedText) throw new Error('缺少可降重的文本内容'); const extraPrompt = String(prompt || '').trim(); + const originalLength = normalizedText.length; + const lengthInstruction = [ + `原文字符数约为 ${originalLength}。`, + '只改写原文含义,不新增无关内容,不删减关键事实。', + '改写后字符数尽量保持在原文的90%-110%范围内。', + '保持当前段落为一个完整段落,不拆分、不合并,不输出标题、解释或引号。', + ].join(''); const requestText = [ '原文:', normalizedText, + '', + '改写要求:', + lengthInstruction, + extraPrompt, ].filter(Boolean).join('\n'); const res = await superagent @@ -916,11 +981,22 @@ const prepareSubmission = async (ctx) => { const meta = metas[index] || {}; const basic = ensureBasicFile(file, index); const rawBuffer = await readFileBuffer(file); - const { buffer, decrypted } = await decryptIfNeeded(ctx, rawBuffer, basic.originalName); + let buffer = rawBuffer; + let decrypted = false; + if (basic.fileExt === 'docx' && !isDocxZipBuffer(buffer)) { + ctx.logger?.info?.(`[plagiarism] decrypt upload ${basic.originalName} ${JSON.stringify(getBufferSignature(buffer))}`); + buffer = await decryptBuffer(ctx, buffer, basic.originalName); + decrypted = true; + } else { + const result = await decryptIfNeeded(ctx, buffer, basic.originalName); + buffer = result.buffer; + decrypted = result.decrypted; + } if (buffer.length > MAX_FILE_SIZE) { throw new Error(`文件 ${basic.originalName} 解密后超过 50MB 限制`); } validateMagicByExt(buffer, basic.fileExt, basic.originalName); + ctx.logger?.info?.(`[plagiarism] prepared upload ${basic.originalName} ${JSON.stringify(getBufferSignature(buffer))} decrypted=${decrypted}`); const docType = getDocTypeForMeta(meta, typeMaps); prepared.push({ ...basic, @@ -984,6 +1060,7 @@ const createTask = async (ctx) => { buffer: item.buffer, mimeType: getMimeByExt(item.fileExt), }); + ctx.logger?.info?.(`[plagiarism] uploaded source task=${task.id} file=${index} ${JSON.stringify(getBufferSignature(item.buffer))} key=${uploaded.qiniuKey}`); const created = await models.PlagiarismCheckFiles.create({ taskId: task.id, docTypeId: item.docType?.id || null, @@ -1348,10 +1425,23 @@ const updateBlockContent = async (ctx, { taskId, fileId, blockId, htmlContent, p const deleteFileBlock = async (ctx, { taskId, fileId, blockId }) => { const block = await getTaskBlock(ctx, { taskId, fileId, blockId }); await ensureTaskCanRecheck(ctx, taskId); + const file = await ctx.app.fs.dc.models.PlagiarismCheckFiles.findOne({ + where: { id: fileId, taskId }, + }); const transaction = await ctx.app.fs.dc.orm.transaction(); try { await clearTaskCheckResults(ctx, taskId, transaction); + const fileMetadata = file?.metadata && typeof file.metadata === 'object' ? { ...file.metadata } : {}; + const deletedParagraphIndexes = Array.isArray(fileMetadata.deletedXmlParagraphIndexes) + ? [...fileMetadata.deletedXmlParagraphIndexes] + : []; + const xmlParagraphIndex = block.structureInfo?.xmlParagraphIndex; + if (xmlParagraphIndex !== undefined && xmlParagraphIndex !== null && !deletedParagraphIndexes.includes(Number(xmlParagraphIndex))) { + deletedParagraphIndexes.push(Number(xmlParagraphIndex)); + fileMetadata.deletedXmlParagraphIndexes = deletedParagraphIndexes; + await file.update({ metadata: fileMetadata, updatedAt: now() }, { transaction }); + } await ctx.app.fs.dc.models.PlagiarismCheckFileBlocks.destroy({ where: { id: blockId, taskId, fileId }, transaction, @@ -1488,11 +1578,14 @@ const generateDeduplicatedBlock = async (ctx, { taskId, fileId, blockId, htmlCon const tagName = String(block?.structureInfo?.tagName || (block.blockType === 'heading' ? 'h2' : 'p')).toLowerCase(); const safeTag = /^h[1-6]$/.test(tagName) || tagName === 'p' ? tagName : 'p'; + const formattedHtml = /<(strong|b|em|i|u|s|del|span|br)\b/i.test(sourceHtml) + ? replaceHtmlTextPreservingMarks(sourceHtml, finalPlainText) + : `<${safeTag}>${escapeHtml(finalPlainText)}`; return { taskId, fileId, blockId, - htmlContent: `<${safeTag}>${escapeHtml(finalPlainText)}`, + htmlContent: formattedHtml, plainText: finalPlainText, textLength: finalPlainText.length, editSource: 'llm', @@ -1515,6 +1608,31 @@ const exportTaskFileDocx = async (ctx, { taskId, fileId }) => { }); if (!blocks.length) throw new Error('当前文件暂无可导出内容'); + if (String(file.fileExt || '').toLowerCase() === 'docx' && file.fileUrl) { + const response = await superagent + .get(file.fileUrl) + .redirects(3) + .responseType('arraybuffer') + .timeout({ response: 30000, deadline: 120000 }); + let sourceBuffer = toBuffer(response.body); + if (!sourceBuffer.length) throw new Error('原始 DOCX 文件为空'); + ctx.logger?.info?.(`[plagiarism] export source task=${taskId} file=${fileId} ${JSON.stringify(getBufferSignature(sourceBuffer))}`); + if (!isDocxZipBuffer(sourceBuffer)) { + ctx.logger?.warn?.(`[plagiarism] export source requires decrypt task=${taskId} file=${fileId}`); + sourceBuffer = await decryptBuffer(ctx, sourceBuffer, file.originalName); + } + validateMagicByExt(sourceBuffer, 'docx', file.originalName); + ctx.logger?.info?.(`[plagiarism] export decrypted source task=${taskId} file=${fileId} ${JSON.stringify(getBufferSignature(sourceBuffer))}`); + const deletedParagraphIndexes = Array.isArray(file.metadata?.deletedXmlParagraphIndexes) + ? file.metadata.deletedXmlParagraphIndexes + : []; + const buffer = await exportDocxFromOriginal({ sourceBuffer, blocks, deletedParagraphIndexes }); + return { + fileName: `${String(file.originalName || 'document').replace(/\.[^.]+$/, '') || 'document'}-编辑版.docx`, + buffer, + }; + } + const html = buildExportDocumentHtml(file, blocks); const buffer = await convertHTMLToDOCX(html); return { @@ -1532,8 +1650,11 @@ const parseOneFile = async (ctx, taskId, item) => { updatedAt: now(), }, { where: { id: fileId } }); try { - const blocks = await parseDocumentToBlocks(ctx, item); + let blocks = await parseDocumentToBlocks(ctx, item); if (!blocks.length) throw new Error('未解析到有效文本内容'); + if (item.fileExt === 'docx') { + blocks = await buildBlockXmlMapping({ sourceBuffer: item.buffer, blocks }); + } const rows = blocks.map(block => ({ taskId, fileId, diff --git a/web/client/src/sections/aiTenderDocument/components/DiagramEditor.jsx b/web/client/src/sections/aiTenderDocument/components/DiagramEditor.jsx index d1fc8d0..4e99cf6 100644 --- a/web/client/src/sections/aiTenderDocument/components/DiagramEditor.jsx +++ b/web/client/src/sections/aiTenderDocument/components/DiagramEditor.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Modal, Button, Space, Input } from "antd"; +import { getMermaidRenderCandidates } from "../utils/mermaidSanitizer"; const DEFAULT_WIDTH = 920; const DEFAULT_HEIGHT = 520; @@ -629,11 +630,33 @@ export default function DiagramEditor({ mermaidLib.initialize({ startOnLoad: false, securityLevel: "loose", + suppressErrorRendering: true, }); mermaidRef.current = mermaidLib; - const id = `diagram-preview-${Date.now()}`; - const { svg } = await mermaidLib.render(id, text); - setPreviewSvg(svg || ""); + const candidates = getMermaidRenderCandidates(text); + let candidateIndex = 0; + + for (const candidate of candidates) { + const id = `diagram-preview-${Date.now()}-${candidateIndex}`; + candidateIndex += 1; + + try { + const { svg } = await mermaidLib.render(id, candidate); + const hasErrorSvg = + !svg || + /aria-roledescription="error"/i.test(svg) || + /Syntax error in text/i.test(svg); + + if (hasErrorSvg) continue; + + setPreviewSvg(svg); + return; + } catch { + // 当前候选源码失败时继续尝试规范化后的源码。 + } + } + + setPreviewSvg(""); }; const schedulePreviewRender = (source) => { @@ -793,13 +816,7 @@ export default function DiagramEditor({ } graph.centerContent(); setLoading(false); - schedulePreviewRender( - convertX6ToMermaid( - graph.toJSON(), - diagramType, - ganttHeaderRef.current - ) - ); + schedulePreviewRender(mermaid || ""); const showPorts = (ports, show) => { ports.forEach((port) => { diff --git a/web/client/src/sections/aiTenderDocument/components/StrongTipTap.jsx b/web/client/src/sections/aiTenderDocument/components/StrongTipTap.jsx index 4a25a36..cb52e5e 100644 --- a/web/client/src/sections/aiTenderDocument/components/StrongTipTap.jsx +++ b/web/client/src/sections/aiTenderDocument/components/StrongTipTap.jsx @@ -26,7 +26,10 @@ import TextStyle from "@tiptap/extension-text-style"; import ImageResize from "tiptap-extension-resize-image"; import { Button, Modal, Space } from "antd"; import DiagramEditor from "./DiagramEditor"; -import { validateAndFixMermaid } from "../utils/mermaidSanitizer"; +import { + getMermaidRenderCandidates, + validateAndFixMermaid, +} from "../utils/mermaidSanitizer"; import { buildPreviewChartOption } from "../../automatedReports/components/insertDataModalUtils"; const toolbarButtonStyle = { padding: "0 6px" }; const CAPTION_CLASS = "ai-caption"; @@ -152,6 +155,7 @@ const MermaidNodeView = ({ node, updateAttributes, editor }) => { startOnLoad: false, theme: "default", securityLevel: "loose", + suppressErrorRendering: true, }); }, []); @@ -163,26 +167,39 @@ const MermaidNodeView = ({ node, updateAttributes, editor }) => { return; } - const { fixedSource } = validateAndFixMermaid(code); - // 生成唯一ID,避免冲突 + const candidates = getMermaidRenderCandidates(code); const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`; + let candidateIndex = 0; - try { - // 尝试渲染 - const renderResult = await mermaid.render(id, fixedSource); - const svgContent = - typeof renderResult === "string" - ? renderResult - : renderResult.svg; - - if (mounted) { - setSvg(svgContent); - } - } catch (e) { - if (mounted) { - setSvg(""); + for (const candidate of candidates) { + const renderId = `${id}-${candidateIndex}`; + candidateIndex += 1; + + try { + const renderResult = await mermaid.render( + renderId, + candidate, + containerRef.current, + ); + const svgContent = + typeof renderResult === "string" + ? renderResult + : renderResult.svg; + const hasErrorSvg = + !svgContent || + /aria-roledescription="error"/i.test(svgContent) || + /Syntax error in text/i.test(svgContent); + + if (hasErrorSvg) continue; + + if (mounted) setSvg(svgContent); + return; + } catch { + // 当前候选源码失败时继续尝试规范化后的源码。 } } + + if (mounted) setSvg(""); }; render(); diff --git a/web/client/src/sections/aiTenderDocument/container/Index.jsx b/web/client/src/sections/aiTenderDocument/container/Index.jsx index 9b2ea0e..6705cf1 100644 --- a/web/client/src/sections/aiTenderDocument/container/Index.jsx +++ b/web/client/src/sections/aiTenderDocument/container/Index.jsx @@ -1159,7 +1159,8 @@ function AiTenderDocumentLayout(props) { return (
{renderMenuContent(menuKey)}
diff --git a/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.jsx b/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.jsx index 27f770c..cb1ea6e 100644 --- a/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.jsx +++ b/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.jsx @@ -11,6 +11,7 @@ import TableCellExt from "@tiptap/extension-table-cell"; import TableHeaderExt from "@tiptap/extension-table-header"; import styles from "./aiPlagiarismCheck.module.less"; import { getAiCenterToken } from "../../../utils/analyticsApp"; +import { renderMermaidToPng } from "../utils/exportToWord"; const DEFAULT_THRESHOLD = 20; @@ -48,6 +49,41 @@ function fixGarbledName(name) { return name; } +const extractMermaidSource = html => { + const container = document.createElement("div"); + container.innerHTML = String(html || ""); + const mermaidNode = container.querySelector("div.mermaid, img[data-mermaid-source], pre[data-mermaid-source], pre code.language-mermaid, pre code.lang-mermaid"); + if (mermaidNode) { + const sourceNode = mermaidNode.matches("img[data-mermaid-source]") || mermaidNode.matches("pre[data-mermaid-source]") + ? mermaidNode + : mermaidNode.querySelector?.("pre[data-mermaid-source]") || mermaidNode; + const source = sourceNode.getAttribute?.("data-mermaid-source") || sourceNode.textContent || ""; + return String(source).replace(/^\s*```mermaid\s*/i, "").replace(/\s*```\s*$/i, "").trim(); + } + const fenced = String(html || "").match(/```mermaid\s*([\s\S]*?)```/i); + return fenced ? fenced[1].trim() : ""; +}; + +const isMermaidContent = html => Boolean(extractMermaidSource(html)); + +const toDataUri = buffer => { + if (!buffer) return ""; + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ""; + bytes.forEach(byte => { binary += String.fromCharCode(byte); }); + return `data:image/png;base64,${btoa(binary)}`; +}; + +const renderMermaidBlockForDocx = async html => { + const source = extractMermaidSource(html); + if (!source) return html; + const rendered = await renderMermaidToPng(source, 600); + const imageData = toDataUri(rendered?.buffer); + if (!imageData) return html; + const safeSource = String(source).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + return `
${safeSource}
Mermaid diagram
`; +}; + export default function AiPlagiarismCheck() { /* ---- Redux ---- */ const dispatch = useDispatch(); @@ -79,7 +115,7 @@ export default function AiPlagiarismCheck() { const processedRef = useRef(new Set()); const warnedRef = useRef(new Set()); const nameMapRef = useRef({}); - const scrollSyncRef = useRef(false); + const counterpartClickIndexRef = useRef(new Map()); /* ---- 派生 ---- */ const visibleIndices = useMemo( @@ -305,19 +341,31 @@ export default function AiPlagiarismCheck() { const blocks = fileBlocksCache[f?.id] || []; const block = blocks[idx]; if (!block) return; + if (isMermaidContent(block.htmlContent || block.plainText)) { + message.info("Mermaid 图表为只读内容"); + return; + } setEditingBlock({ docIdx, idx, fileId: f?.id, blockId: block.id, taskId }); setEditContent(block.htmlContent || block.plainText); }, [taskFiles, fileBlocksCache, taskId]); - // 点击一侧匹配段落时,跳转到另一侧对应段落并高亮 - const jumpToCounterpart = useCallback((docIdx, idx) => { + // 点击一侧匹配段落时,按顺序跳转到另一侧的多个对应段落。 + const jumpToCounterpart = useCallback((docIdx, idx, preferredMatchId = null) => { const related = matches.filter( (m) => (m.leftIdx === docIdx && m.leftPara === idx) || (m.rightIdx === docIdx && m.rightPara === idx), ); if (!related.length) return; - const match = related[0]; + const sourceKey = docIdx + "-" + idx; + const clickIndex = counterpartClickIndexRef.current.get(sourceKey) || 0; + const preferredMatch = preferredMatchId + ? related.find((item) => item.id === preferredMatchId) + : null; + const match = preferredMatch || related[clickIndex % related.length]; + if (!preferredMatch) { + counterpartClickIndexRef.current.set(sourceKey, (clickIndex + 1) % related.length); + } setCurrentFocusId(match.id); const targetDocIdx = match.leftIdx === docIdx ? match.rightIdx : match.leftIdx; const targetPara = match.leftIdx === docIdx ? match.rightPara : match.leftPara; @@ -332,58 +380,19 @@ export default function AiPlagiarismCheck() { }); } - requestAnimationFrame(() => { + const scrollToTarget = () => { const targetEl = document.getElementById(targetId); - const targetPane = targetEl?.closest("." + styles.pane); - if (!targetEl || !targetPane) return; + const targetPaneContent = targetEl?.closest("." + styles.paneContent); + if (!targetEl || !targetPaneContent) return; - const paneRect = targetPane.getBoundingClientRect(); + const paneRect = targetPaneContent.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); - const targetTop = targetPane.scrollTop + targetRect.top - paneRect.top; - const centeredTop = targetTop - (targetPane.clientHeight - targetEl.offsetHeight) / 2; - targetPane.scrollTo({ top: Math.max(0, centeredTop), behavior: "smooth" }); - }); - }, [matches, hiddenFiles, styles.pane]); - - // 按中缝匹配关系同步双屏滚动,不按两个文档总高度做比例同步。 - const syncCounterpartScroll = useCallback((docIdx, sourcePane) => { - if (scrollSyncRef.current || !sourcePane || !matches.length) return; - - const sourcePaneRect = sourcePane.getBoundingClientRect(); - const candidates = matches - .filter((match) => match.leftIdx === docIdx || match.rightIdx === docIdx) - .map((match) => { - const sourcePara = match.leftIdx === docIdx ? match.leftPara : match.rightPara; - const targetDocIdx = match.leftIdx === docIdx ? match.rightIdx : match.leftIdx; - const targetPara = match.leftIdx === docIdx ? match.rightPara : match.leftPara; - const sourceEl = document.getElementById("doc-container-" + docIdx + "-" + sourcePara); - const targetEl = document.getElementById("doc-container-" + targetDocIdx + "-" + targetPara); - if (!sourceEl || !targetEl) return null; - return { - sourceEl, - targetEl, - distance: Math.abs(sourceEl.getBoundingClientRect().top - sourcePaneRect.top), - }; - }) - .filter(Boolean) - .sort((a, b) => a.distance - b.distance); - - const current = candidates[0]; - const targetPane = current?.targetEl.closest("." + styles.pane); - if (!current || !targetPane) return; - - const sourceTop = current.sourceEl.getBoundingClientRect().top - sourcePaneRect.top; - const targetPaneRect = targetPane.getBoundingClientRect(); - const targetTop = targetPane.scrollTop + current.targetEl.getBoundingClientRect().top - targetPaneRect.top; - const nextTop = Math.max(0, targetTop - sourceTop); - if (Math.abs(targetPane.scrollTop - nextTop) < 2) return; - - scrollSyncRef.current = true; - targetPane.scrollTop = nextTop; - requestAnimationFrame(() => { - scrollSyncRef.current = false; - }); - }, [matches, styles.pane]); + const targetTop = targetPaneContent.scrollTop + targetRect.top - paneRect.top; + const centeredTop = targetTop - (targetPaneContent.clientHeight - targetEl.offsetHeight) / 2; + targetPaneContent.scrollTo({ top: Math.max(0, centeredTop), behavior: "smooth" }); + }; + requestAnimationFrame(() => requestAnimationFrame(scrollToTarget)); + }, [matches, hiddenFiles, styles.paneContent]); const applyAiRewrite = useCallback(async () => { if (!editingBlock || !editingBlock.taskId || !editingBlock.fileId || !editingBlock.blockId) { @@ -413,22 +422,31 @@ export default function AiPlagiarismCheck() { } }, [editingBlock, editContent]); - const saveEdit = useCallback(() => { + const saveEdit = useCallback(async () => { if (!editingBlock || !editingBlock.taskId || !editingBlock.fileId || !editingBlock.blockId) { message.warning("无法保存:缺少必要信息"); return; } const { taskId: tid, fileId: fid, blockId: bid, docIdx, idx } = editingBlock; + const source = extractMermaidSource(editContent); + let nextHtmlContent = editContent; + if (source) { + try { + nextHtmlContent = await renderMermaidBlockForDocx(editContent); + } catch (error) { + message.warning("Mermaid 图表渲染失败,将保留原内容"); + } + } const plainText = editContent.replace(/<[^>]+>/g, ""); // 先调保存接口 dispatch(actions.plagiarismCheckSaveBlock({ params: { taskId: tid, fileId: fid, blockId: bid }, - body: { htmlContent: editContent, plainText, editSource: "manual" }, + body: { htmlContent: nextHtmlContent, plainText, editSource: "manual" }, })).then(() => { // 更新本地缓存 setFileBlocksCache((prev) => { const fileBlocks = [...(prev[fid] || [])]; if (fileBlocks[idx]) { - fileBlocks[idx] = { ...fileBlocks[idx], htmlContent: editContent, plainText }; + fileBlocks[idx] = { ...fileBlocks[idx], htmlContent: nextHtmlContent, plainText }; } return { ...prev, [fid]: fileBlocks }; }); @@ -635,6 +653,7 @@ export default function AiPlagiarismCheck() { const isEditing = editingBlock && editingBlock.docIdx === docIdx && editingBlock.idx === idx; const html = p.htmlContent || p.plainText || ""; const plain = p.plainText || html.replace(/<[^>]+>/g, ""); + const mermaidOnly = isMermaidContent(html); if (isEditing) { return ( @@ -644,9 +663,9 @@ export default function AiPlagiarismCheck() {
-
@@ -670,7 +689,7 @@ export default function AiPlagiarismCheck() { return (

jumpToCounterpart(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} /> -

); } @@ -699,13 +718,13 @@ export default function AiPlagiarismCheck() { return (

jumpToCounterpart(docIdx, idx)}>{parts}

-
); } } - return (

openEditor(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} />

); + return (

openEditor(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} />

); }; const renderResult = () => ( @@ -756,21 +775,19 @@ export default function AiPlagiarismCheck() { const blocks = fileBlocksCache[file?.id] || []; return (
-
{ - syncCounterpartScroll(docIdx, event.currentTarget); - redrawAll(); - }}> +
{(nameMapRef.current[file?.originalName] || fixGarbledName(file?.originalName) || "文档" + (docIdx + 1))} - -
-
- {blocks.length > 0 ? blocks.map((b, i) => renderParagraph(b, i, docIdx)) - : (
加载中...
)} + + + +
-
- 共 {blocks.length} 段 | {formatFileSize(file?.fileSize || 0)} | {file?.parseStatus || ""} - +
+
+ {blocks.length > 0 ? blocks.map((b, i) => renderParagraph(b, i, docIdx)) + : (
加载中...
)} +
{!isLast && (() => { @@ -780,7 +797,7 @@ export default function AiPlagiarismCheck() {
{ if (el) requestAnimationFrame(() => redrawAll()); }}>
- {gm.map((m) => (
setCurrentFocusId((prev) => prev === m.id ? null : m.id)}>
))} + {gm.map((m) => (
jumpToCounterpart(m.leftIdx, m.leftPara, m.id)}>
))}
); })()} diff --git a/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.module.less b/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.module.less index 2627363..c16850a 100644 --- a/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.module.less +++ b/web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.module.less @@ -7,7 +7,7 @@ /* ========== 上传页 ========== */ .uploadPage { height: 100%; - overflow-y: auto; + overflow-y: scroll; display: flex; align-items: flex-start; justify-content: center; @@ -215,10 +215,12 @@ /* ========== 结果页 ========== */ .resultPage { height: 100%; + min-height: 0; display: flex; flex-direction: column; background: transparent; overflow: hidden; + position: relative; } /* 顶部信息栏 */ @@ -279,8 +281,11 @@ flex-direction: row; flex-wrap: nowrap; overflow-x: auto; - overflow-y: hidden; + overflow-y: auto; min-height: 0; + align-items: stretch; + padding-bottom: 0; + box-sizing: border-box; } .scrollContainer::-webkit-scrollbar { @@ -301,6 +306,7 @@ flex-direction: row; flex-shrink: 0; height: 100%; + min-height: 0; } /* 文档面板 */ @@ -308,7 +314,10 @@ width: calc(50vw - 48px); min-width: 320px; flex-shrink: 0; - overflow-y: auto; + height: 100%; + min-height: 0; + box-sizing: border-box; + overflow: hidden; resize: horizontal; display: flex; flex-direction: column; @@ -337,16 +346,17 @@ } .pane::-webkit-scrollbar { - width: 5px; + width: 8px; } .pane::-webkit-scrollbar-track { - background: transparent; + background: #e2e8f0; } .pane::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 3px; + background: #94a3b8; + border-radius: 4px; + border: 2px solid #e2e8f0; } .paneHeader { @@ -368,6 +378,36 @@ display: flex; align-items: center; justify-content: space-between; + position: relative; + z-index: 4; + background: rgba(241, 245, 249, 0.96); +} + +.paneContent { + width: 100%; + flex: 1 1 auto; + min-height: 0; + overflow-y: scroll; + overflow-x: hidden; + padding-bottom: 64px; + scroll-padding-bottom: 64px; + box-sizing: border-box; + scrollbar-width: auto; + scrollbar-color: #94a3b8 #e2e8f0; +} + +.paneContent::-webkit-scrollbar { + width: 10px; +} + +.paneContent::-webkit-scrollbar-track { + background: #e2e8f0; +} + +.paneContent::-webkit-scrollbar-thumb { + background: #64748b; + border-radius: 5px; + border: 2px solid #e2e8f0; } .emptyContent { @@ -388,11 +428,14 @@ /* Word 纸张样式 */ .wordDoc { width: 80%; + max-width: 760px; + margin: 0 auto; + flex: 0 0 auto; background: #fff; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.05); border: 1px solid #cbd5e1; - padding: 40px 36px; + padding: 40px 36px 88px; display: flex; flex-direction: column; gap: 20px; diff --git a/web/client/src/sections/aiTenderDocument/container/index.module.less b/web/client/src/sections/aiTenderDocument/container/index.module.less index 430aa5d..816ab43 100644 --- a/web/client/src/sections/aiTenderDocument/container/index.module.less +++ b/web/client/src/sections/aiTenderDocument/container/index.module.less @@ -638,6 +638,16 @@ height: 100%; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; +} + +.activeMenuPanel { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; } .homePlaceholder { diff --git a/web/client/src/sections/aiTenderDocument/utils/exportToWord.js b/web/client/src/sections/aiTenderDocument/utils/exportToWord.js index 2fe4d26..b1b7aa1 100644 --- a/web/client/src/sections/aiTenderDocument/utils/exportToWord.js +++ b/web/client/src/sections/aiTenderDocument/utils/exportToWord.js @@ -2160,6 +2160,9 @@ const renderMermaidToSvg = async (code, maxWidth) => { } }; + +export const renderMermaidToPng = renderMermaidToSvg; + // ✅ HTML 行内解析(strong 不再导致换行) const processHtmlInline = (html, style = {}, shouldSplitParagraphs = false) => { const runs = []; diff --git a/web/client/src/sections/aiTenderDocument/utils/mermaidSanitizer.js b/web/client/src/sections/aiTenderDocument/utils/mermaidSanitizer.js index 6aaedba..15f1311 100644 --- a/web/client/src/sections/aiTenderDocument/utils/mermaidSanitizer.js +++ b/web/client/src/sections/aiTenderDocument/utils/mermaidSanitizer.js @@ -122,3 +122,100 @@ export function validateAndFixMermaid(raw) { warnings, }; } + +const normalizeEscapedNewlines = (raw) => { + const source = String(raw || ""); + let result = ""; + let bracketDepth = 0; + let parenthesisDepth = 0; + let braceDepth = 0; + let pipeLabel = false; + + for (let index = 0; index < source.length; index += 1) { + const current = source[index]; + const next = source[index + 1]; + const inLabel = + bracketDepth > 0 || + parenthesisDepth > 0 || + braceDepth > 0 || + pipeLabel; + + if (current === "\\" && next === "n") { + result += inLabel ? "
" : "\n"; + index += 1; + continue; + } + + if (current === "\n") { + result += inLabel ? "
" : "\n"; + continue; + } + + if (current === "[") bracketDepth += 1; + if (current === "]" && bracketDepth > 0) bracketDepth -= 1; + if (current === "(") parenthesisDepth += 1; + if (current === ")" && parenthesisDepth > 0) parenthesisDepth -= 1; + if (current === "{") braceDepth += 1; + if (current === "}" && braceDepth > 0) braceDepth -= 1; + if ( + current === "|" && + bracketDepth === 0 && + parenthesisDepth === 0 && + braceDepth === 0 + ) { + pipeLabel = !pipeLabel; + } + + result += current; + } + + return result; +}; + +const normalizeMermaidRenderSource = (raw) => { + let text = String(raw || "") + .replace(/\r\n?/g, "\n") + .replace(//gi, "\n") + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[:]/g, ":") + .replace(/\u00a0/g, " "); + + text = normalizeEscapedNewlines(text) + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*code\b[:\s]*/gim, "") + .replace(/^\s*```(?:mermaid)?\s*/gim, "") + .replace(/^\s*~~~(?:mermaid)?\s*/gim, "") + .replace(/^\s*subgraph\s+(.+)$/gim, (_, name) => { + const trimmedName = String(name || "").trim(); + if (!trimmedName || /^["'].*["']$/.test(trimmedName)) { + return `subgraph ${trimmedName}`.trim(); + } + if (/[\s\u4e00-\u9fa5]/.test(trimmedName)) { + return `subgraph "${trimmedName}"`; + } + return `subgraph ${trimmedName}`; + }) + .trim(); + + return text; +}; + +/** + * 功能:生成 Mermaid 节点视图可依次尝试的渲染源码。 + * 使用场景:兼容接口返回的转义换行、中文 subgraph 和旧 Mermaid 源码。 + * + * 入参:原始 Mermaid 源码。 + * + * 返回:去重后的候选源码数组,首项为常规清洗结果。 + * + * 注意:调用方应依次尝试候选源码,不能在首个候选失败后直接清空预览。 + */ +export function getMermaidRenderCandidates(raw) { + const { fixedSource } = validateAndFixMermaid(raw); + const candidates = [fixedSource, normalizeMermaidRenderSource(fixedSource)]; + + return [...new Set(candidates.map((item) => String(item || "").trim()))].filter( + Boolean + ); +} diff --git a/web/client/src/sections/superAgent/components/SuperAgentWorkspace.jsx b/web/client/src/sections/superAgent/components/SuperAgentWorkspace.jsx index f05ada7..600b965 100644 --- a/web/client/src/sections/superAgent/components/SuperAgentWorkspace.jsx +++ b/web/client/src/sections/superAgent/components/SuperAgentWorkspace.jsx @@ -752,7 +752,9 @@ const SuperAgentWorkspace = ({ title={headingText} content={content} chapterId={item.id} - chapterNumber={index + 1} + chapterNumber={item.chapterNumber} + figureStartIndex={item.figureStartIndex} + tableStartIndex={item.tableStartIndex} captionStyle={bodyExportSettings.captionStyle} documentStyle={bodyDocumentStyle} documentTitleLevel={item.parentDisplayTitle ? "h2" : "h1"} diff --git a/web/client/src/sections/superAgent/components/TaskProgressCard.jsx b/web/client/src/sections/superAgent/components/TaskProgressCard.jsx index 6ec1f3c..43acd5d 100644 --- a/web/client/src/sections/superAgent/components/TaskProgressCard.jsx +++ b/web/client/src/sections/superAgent/components/TaskProgressCard.jsx @@ -31,20 +31,10 @@ const TaskProgressCard = ({ item = {}, onOpenWorkspace, onDownloadFile }) => { if (taskType !== "artifact_build") return null; const taskStatus = String(task.status || "").toLowerCase(); - const sessionStatus = String( - task.session_status || task.sessionStatus || "" - ).toLowerCase(); - const isFailed = - ["failed", "error", "cancelled", "canceled"].includes(taskStatus) || - ["failed", "error", "cancelled", "canceled"].includes(sessionStatus); - const isTaskCompleted = - Number(task.progress || 0) >= 100 || - ["writed", "done", "completed", "success", "reviewing"].includes(taskStatus); - const isCompleted = - isTaskCompleted && - (!sessionStatus || sessionStatus === "completed"); - const isSuspended = taskStatus === "suspended" || sessionStatus === "suspended"; + const isFailed = ["failed", "error", "cancelled", "canceled"].includes(taskStatus); const progress = Math.min(100, Math.max(0, Math.round(Number(task.progress || 0)))); + const isCompleted = progress > 0 && taskStatus === "completed"; + const isSuspended = taskStatus === "suspended"; const done = Number(task.done || 0); const hasExportContent = done > 0 || progress > 0 || isCompleted; const fileName = String( diff --git a/web/client/src/sections/superAgent/components/workspace/TreeMarkdownWorkspace.jsx b/web/client/src/sections/superAgent/components/workspace/TreeMarkdownWorkspace.jsx index 127f24a..a6b086f 100644 --- a/web/client/src/sections/superAgent/components/workspace/TreeMarkdownWorkspace.jsx +++ b/web/client/src/sections/superAgent/components/workspace/TreeMarkdownWorkspace.jsx @@ -578,7 +578,9 @@ const TreeMarkdownWorkspace = ({ title={item.displayTitle} content={content} chapterId={sectionId} - chapterNumber={index + 1} + chapterNumber={item.chapterNumber} + figureStartIndex={item.figureStartIndex} + tableStartIndex={item.tableStartIndex} captionStyle={bodySettings.captionStyle} documentStyle={bodyDocumentStyle} documentTitleLevel={item.parentDisplayTitle ? "h2" : "h1"} diff --git a/web/client/src/sections/superAgent/constants.js b/web/client/src/sections/superAgent/constants.js index ae09877..087572a 100644 --- a/web/client/src/sections/superAgent/constants.js +++ b/web/client/src/sections/superAgent/constants.js @@ -191,21 +191,21 @@ export const TASK_STAGE = { export const quickTaskList = [ { key: "tender", - title: "投标文件生成", + title: "AI投标", desc: "对话式完成解析、评分点、目录和正文生成。", enabled: true, icon: ShortcutArticleIcon, }, { key: "legal", - title: "法务评估", + title: "AI法务", desc: "归集项目和材料,生成评估报告和起诉状。", - enabled: false, + enabled: true, icon: KnowledgeIcon, }, { key: "plan", - title: "项目方案生成", + title: "AI方案", desc: "后续接入", enabled: false, icon: DeepIcon, diff --git a/web/client/src/sections/superAgent/container/index.jsx b/web/client/src/sections/superAgent/container/index.jsx index d5aa98b..d295919 100644 --- a/web/client/src/sections/superAgent/container/index.jsx +++ b/web/client/src/sections/superAgent/container/index.jsx @@ -71,99 +71,6 @@ import "./index.less"; const MOBILE_LAYOUT_BREAKPOINT = 768; -const COMPLETED_SECTION_STATUSES = new Set([ - "done", - "completed", - "success", - "generated", - "writed", - "finished", - "ready", - "drafted", - "published", - "available", - "edited", -]); - -//[根据章节接口结果生成正文任务进度,兼容后端只返回章节列表的场景] -const buildContentProgressTask = ({ - sectionList = [], - previousTask = {}, - payload = {}, - statusOverride = "", -} = {}) => { - const taskPayload = payload.task || payload.data?.task || {}; - const sections = Array.isArray(sectionList) ? sectionList : []; - const payloadTotal = Number(taskPayload.total ?? payload.total ?? previousTask.total ?? 0); - const total = Math.max(Number.isFinite(payloadTotal) ? payloadTotal : 0, sections.length); - const generatedCount = sections.filter((section) => { - const status = String(section?.status || section?.section_status || "").toLowerCase(); - return ( - COMPLETED_SECTION_STATUSES.has(status) || - section?.completed === true || - section?.generated === true || - section?.done === true || - Boolean(String(section?.content || "").trim()) - ); - }).length; - const payloadDone = Number(taskPayload.done ?? payload.done ?? previousTask.done ?? 0); - const done = Math.min( - total || Math.max(generatedCount, payloadDone), - Math.max(generatedCount, Number.isFinite(payloadDone) ? payloadDone : 0) - ); - const status = - statusOverride || - taskPayload.status || - payload.status || - previousTask.status || - (total > 0 && done >= total ? "completed" : "running"); - const normalizedStatus = String(status || "").toLowerCase(); - const completedTaskStatuses = new Set([ - "completed", - "done", - "success", - "generated", - "finished", - "ready", - ]); - const hasIncompleteSections = total > 0 && done < total; - const shouldKeepGenerating = - hasIncompleteSections && completedTaskStatuses.has(normalizedStatus); - const effectiveStatus = shouldKeepGenerating - ? previousTask.status || "running" - : status; - const isCompleted = - completedTaskStatuses.has(String(effectiveStatus || "").toLowerCase()) || - (total > 0 && done >= total); - const rawProgress = Number(taskPayload.progress ?? payload.progress ?? previousTask.progress ?? 0); - const calculatedProgress = total > 0 ? Math.round((done / total) * 100) : 0; - - return { - ...previousTask, - ...payload, - ...taskPayload, - task_type: taskPayload.task_type || taskPayload.taskType || payload.task_type || previousTask.task_type || "artifact_build", - section_title: taskPayload.section_title || taskPayload.sectionTitle || "正文", - status: isCompleted ? "completed" : effectiveStatus, - done: isCompleted && total > 0 ? total : done, - total, - sections: sections.length - ? sections - : Array.isArray(previousTask.sections) - ? previousTask.sections - : [], - progress: isCompleted - ? 100 - : Math.round( - Math.max( - Number.isFinite(rawProgress) ? rawProgress : 0, - calculatedProgress, - Number(previousTask.progress || 0) - ) - ), - }; -}; - const getLocalMockUserId = () => { if (typeof window === "undefined") return ""; const hostname = String(window.location?.hostname || "").trim(); @@ -182,25 +89,6 @@ const getSuperAgentRequestUserId = () => { return localUserId || userId; }; -const WAITING_USER_SESSION_STATUSES = new Set([ - "waiting_user", - "waiting", - "waiting_for_input", - "waiting_interrupt", - "interrupt", - "interrupted", - "suspended", -]); - -const TERMINAL_SESSION_STATUSES = new Set([ - "failed", - "error", - "cancelled", - "canceled", - "stopped", - "suspended", -]); - const getCheckpointId = (checkpoint = {}) => String( checkpoint?.checkpoint_id || @@ -260,7 +148,6 @@ const SuperAgent = () => { const actions = useSelector((state) => state.global?.actions); const [collapsed, setCollapsed] = useState(false); const [workspaceOpen, setWorkspaceOpen] = useState(false); - const [restoredSessionTask, setRestoredSessionTask] = useState(null); const [workspaceWidth, setWorkspaceWidth] = useState(null); const [activeAgentApp, setActiveAgentApp] = useState(null); const [isMobileLayout, setIsMobileLayout] = useState(false); @@ -275,8 +162,6 @@ const SuperAgent = () => { const contentWorkspaceRefreshAtRef = useRef(0); const contentWorkspaceRefreshInFlightRef = useRef(false); const checkpointSubmittingIdsRef = useRef(new Set()); - const completedSectionIdsRef = useRef(new Set()); - const latestProgressTaskRef = useRef(null); const activeSessionRestoredRef = useRef(false); const transientPublishUiIdsRef = useRef(new Set()); const publishUiBaselineIdsRef = useRef(new Set()); @@ -499,17 +384,10 @@ const SuperAgent = () => { useEffect(() => { activeSessionIdRef.current = String(activeSessionId || ""); - completedSectionIdsRef.current.clear(); - latestProgressTaskRef.current = null; - setRestoredSessionTask(null); setDismissedGeneratedInterruptIds(new Set()); setHiddenCheckpointIds(new Set()); }, [activeSessionId]); - useEffect(() => { - latestProgressTaskRef.current = restoredSessionTask; - }, [restoredSessionTask]); - const workspace = useSuperAgentWorkspace({ workspaceOpen, setWorkspaceOpen, @@ -556,252 +434,6 @@ const SuperAgent = () => { syncSessionProgressSections, } = workspace; - const commitProgressTask = useCallback((sessionId, progressData, payload = {}) => { - const rawProgress = Number(progressData?.progress); - const progressStatus = String(progressData?.status || "").toLowerCase(); - const sessionStatus = String( - payload.status || payload.task_status || "" - ).toLowerCase(); - const isCompletedProgress = [ - "completed", - "done", - "success", - ].includes(progressStatus); - const hasTerminalSessionStatus = TERMINAL_SESSION_STATUSES.has(sessionStatus); - const normalizedProgressData = { - ...progressData, - session_id: sessionId, - session_status: - payload.session_status || - payload.sessionStatus || - (isCompletedProgress - ? "completed" - : progressData.session_status || ""), - ...(hasTerminalSessionStatus ? { status: sessionStatus } : {}), - progress: Number.isFinite(rawProgress) ? Math.round(rawProgress) : 0, - }; - latestProgressTaskRef.current = normalizedProgressData; - setRestoredSessionTask(normalizedProgressData); - updateActiveTask({ - status: normalizedProgressData.status, - progress: normalizedProgressData.progress, - done: normalizedProgressData.done, - total: normalizedProgressData.total, - message: normalizedProgressData.message || payload.message || payload.summary || "", - }); - upsertAssistantMessage({ - key: `session_progress_${sessionId}`, - role: "assistant", - type: "taskProgress", - title: "正文生成", - task: normalizedProgressData, - sessionId, - }); - syncSessionProgressSections({ sessionId, progressData: normalizedProgressData }); - }, [ - syncSessionProgressSections, - updateActiveTask, - upsertAssistantMessage, - ]); - const commitProgressTaskRef = useRef(commitProgressTask); - - useEffect(() => { - commitProgressTaskRef.current = commitProgressTask; - }, [commitProgressTask]); - - const handleBusinessInvalidation = useCallback(async (payload = {}) => { - const eventType = String(payload.type || ""); - const sessionId = payload.task_id || payload.taskId || activeSessionId; - if (!sessionId || sessionId !== activeSessionId) return; - const canRefreshCurrentWorkspace = - workspaceOpen && - String(workspaceData?.sessionId || "") === String(sessionId || ""); - const isGenerationProgress = [ - "generation.started", - "generation.progress", - ].includes(eventType); - const isSectionProgress = [ - "section.started", - "section.progress", - "section.completed", - "section.failed", - "section.updated", - "sections.updated", - ].includes(eventType); - if (isGenerationProgress || isSectionProgress) { - const rawTask = payload.task || payload.data?.task || {}; - const rawSection = - payload.section || - payload.data?.section || - (!Array.isArray(payload.data) ? payload.data : {}) || - {}; - const updatedSections = Array.isArray(payload.sections) - ? payload.sections - : Array.isArray(payload.data?.sections) - ? payload.data.sections - : []; - const isCompletedSection = (section) => { - const status = String(section?.status || section?.section_status || "").toLowerCase(); - return ( - COMPLETED_SECTION_STATUSES.has(status) || - section?.completed === true || - section?.generated === true || - section?.done === true || - Boolean(String(section?.content || "").trim()) - ); - }; - const updatedCompletedCount = updatedSections.filter(isCompletedSection).length; - const sectionId = String( - rawTask.section_id || - rawTask.sectionId || - rawSection.id || - rawSection.section_id || - rawSection.sectionId || - payload.section_id || - payload.sectionId || - "" - ).trim(); - const isSectionCompleted = - eventType === "section.completed" || - ((eventType === "section.updated" || eventType === "sections.updated") && - isCompletedSection(rawSection)); - if (eventType === "generation.started") { - completedSectionIdsRef.current.clear(); - } - if (isSectionCompleted && sectionId) { - completedSectionIdsRef.current.add(sectionId); - } - const previousTask = latestProgressTaskRef.current || {}; - const rawDone = Number(rawTask.done ?? payload.done); - const rawTotal = Number(rawTask.total ?? payload.total); - const done = Math.max( - Number.isFinite(rawDone) ? rawDone : 0, - completedSectionIdsRef.current.size, - updatedCompletedCount, - Number(previousTask.done || 0) - ); - const total = Math.max( - Number.isFinite(rawTotal) ? rawTotal : 0, - Number(previousTask.total || 0) - ); - const rawProgress = Number(rawTask.progress ?? payload.progress); - const calculatedProgress = total > 0 ? (done / total) * 100 : 0; - const progress = Math.max( - Number.isFinite(rawProgress) ? rawProgress : 0, - calculatedProgress, - Number(previousTask.progress || 0) - ); - const status = eventType === "section.failed" ? "failed" : "running"; - const progressData = { - ...payload, - ...rawTask, - session_id: sessionId, - task_type: rawTask.task_type || rawTask.taskType || payload.task_type || previousTask.task_type || "artifact_build", - session_status: "running", - status, - progress, - done, - total, - event_type: eventType, - section_id: sectionId, - section_title: - rawTask.section_title || - rawTask.sectionTitle || - rawSection.title || - rawSection.name || - payload.section_title || - payload.sectionTitle || - "", - section_status: isSectionCompleted ? "completed" : rawSection.status || "", - }; - commitProgressTask(sessionId, progressData, payload); - if (payload.refresh_required && canRefreshCurrentWorkspace) { - const refreshedSections = await refreshSessionContentWorkspace({ - sessionId, - silent: true, - }); - if (Array.isArray(refreshedSections)) { - commitProgressTask( - sessionId, - buildContentProgressTask({ - sectionList: refreshedSections, - previousTask: progressData, - payload, - }) - ); - } - } - } - if (eventType === "workspace.updated" || eventType === "sections.rewrite.requested") { - const previousTask = latestProgressTaskRef.current || {}; - commitProgressTask( - sessionId, - buildContentProgressTask({ - previousTask, - payload, - }) - ); - if (!canRefreshCurrentWorkspace) return; - window.setTimeout(async () => { - const refreshedSections = await refreshSessionContentWorkspace({ - sessionId, - silent: true, - }); - if (Array.isArray(refreshedSections)) { - commitProgressTask( - sessionId, - buildContentProgressTask({ - sectionList: refreshedSections, - previousTask: latestProgressTaskRef.current || previousTask, - payload, - }) - ); - } - }, 0); - } - if ([ - "generation.completed", - "generation.failed", - "generation.cancelled", - "generation.error", - ].includes(eventType)) { - commitProgressTask( - sessionId, - buildContentProgressTask({ - previousTask: latestProgressTaskRef.current || {}, - payload, - statusOverride: eventType === "generation.completed" ? "completed" : "failed", - }) - ); - if (eventType === "generation.completed" && canRefreshCurrentWorkspace) { - window.setTimeout(async () => { - const refreshedSections = await refreshSessionContentWorkspace({ - sessionId, - silent: true, - }); - if (Array.isArray(refreshedSections)) { - commitProgressTask( - sessionId, - buildContentProgressTask({ - sectionList: refreshedSections, - previousTask: latestProgressTaskRef.current || {}, - payload, - statusOverride: "completed", - }) - ); - } - }, 0); - } - } - if (eventType === "section.failed") message.warning(payload.message || "章节生成失败"); - }, [ - activeSessionId, - commitProgressTask, - refreshSessionContentWorkspace, - workspaceData?.sessionId, - workspaceOpen, - ]); - const handleProtocolError = useCallback((error) => { message.error(getSuperAgentErrorMessage(error, "智能体服务暂不可用")); }, []); @@ -809,7 +441,6 @@ const SuperAgent = () => { const stream = useSuperAgentStream({ activeSessionId, userId: currentUserId, - onBusinessInvalidation: handleBusinessInvalidation, onStreamError: handleProtocolError, }); @@ -849,79 +480,28 @@ const SuperAgent = () => { ["awaiting_interrupt", "waiting_interrupt"].includes( String(stream.runPhase || "").toLowerCase() ); - const restoredTaskSessionId = String( - restoredSessionTask?.session_id || - restoredSessionTask?.sessionId || - restoredSessionTask?.task_id || - restoredSessionTask?.taskId || - "" - ); - const activeRestoredSessionTask = - restoredTaskSessionId === String(activeSessionId || "") - ? restoredSessionTask - : null; - //[正文任务状态只来自当前 useStream thread 和其业务事件] - const latestBusinessProgressTask = - String( - latestProgressTaskRef.current?.session_id || - latestProgressTaskRef.current?.sessionId || - "" - ) === String(activeSessionId || "") - ? latestProgressTaskRef.current - : null; - const runtimeTaskSources = { - ...(activeRestoredSessionTask || {}), - ...(activeStreamTask || {}), - // 业务 custom 事件比 SDK 可能滞后的 values 快照更新更快,终态不能被旧 task 覆盖。 - ...(latestBusinessProgressTask || {}), - }; - const runtimeTaskSourceType = String( - runtimeTaskSources?.task_type || runtimeTaskSources?.taskType || "" - ).toLowerCase(); - const runtimeTask = runtimeTaskSources; + //[正文任务状态只来自当前 useStream thread 的 task.status] + const runtimeTask = activeStreamTask || {}; const taskStatus = String(runtimeTask?.status || "").toLowerCase(); - const runtimeSessionStatus = String( - runtimeTask.session_status || - runtimeTask.sessionStatus || - "" - ).toLowerCase(); - const hasLocalRunSignal = stream.isLocalRunActive === true; const hasStreamError = stream.isExecutionFailed === true || Boolean(stream.error); const runtimeTaskType = String( - runtimeTask?.task_type || runtimeTask?.taskType || "" - ).toLowerCase(); + runtimeTask?.task_type || runtimeTask?.taskType || "" + ).toLowerCase(); const runtimeTaskTotal = Number(runtimeTask?.total || 0); - const hasPersistedActiveThread = - streamReadyForActiveSession && stream.isPersistedThreadActive === true; - const displayTaskStatus = - hasLocalRunSignal && ["failed", "error"].includes(taskStatus) - ? "running" - : taskStatus; - const displayRuntimeSessionStatus = - hasLocalRunSignal && ["failed", "error"].includes(runtimeSessionStatus) - ? "running" - : runtimeSessionStatus; + const hasPersistedActiveThread = + streamReadyForActiveSession && stream.isPersistedThreadActive === true; + const displayTaskStatus = taskStatus; const isFailedTask = hasStreamError || ["failed", "error"].includes(displayTaskStatus) || - ["failed", "error"].includes(displayRuntimeSessionStatus) || - (stream.runPhase === "failed" && !hasPendingInterruptSignal && !hasLocalRunSignal); - const hasSuspendedRuntimeStatus = - displayTaskStatus === "suspended" || displayRuntimeSessionStatus === "suspended"; + (stream.runPhase === "failed" && !hasPendingInterruptSignal); + const hasSuspendedRuntimeStatus = displayTaskStatus === "suspended"; const displayActiveStreamTask = activeStreamTask ? { ...activeStreamTask, status: displayTaskStatus || activeStreamTask.status, - session_status: - displayRuntimeSessionStatus || - activeStreamTask.session_status || - activeStreamTask.sessionStatus || - "", } : activeStreamTask; - const isSuspendedByInterrupt = - hasPendingInterruptSignal && - (taskStatus === "suspended" || runtimeSessionStatus === "suspended"); const hasPersistedRunningThread = hasPersistedActiveThread && !hasSuspendedRuntimeStatus && !isFailedTask; const hasTerminalStreamRunPhase = [ @@ -932,14 +512,11 @@ const SuperAgent = () => { "aborted", "stopped", ].includes(stream.runPhase); - const isTerminalRuntimeSession = [ - "failed", - "completed", - "cancelled", - "canceled", - "stopped", - "suspended", - ].includes(displayRuntimeSessionStatus) && !isSuspendedByInterrupt; + const terminalStreamStatus = hasTerminalStreamRunPhase + ? stream.runPhase === "aborted" + ? "completed" + : stream.runPhase + : ""; const runtimeBodyTaskShape = runtimeTaskType === "artifact_build" || (taskStage === TASK_STAGE.CONTENT && runtimeTaskTotal > 0); @@ -960,9 +537,7 @@ const SuperAgent = () => { "resuming", "awaiting_interrupt", ].includes(displayTaskStatus) && - (displayRuntimeSessionStatus - ? displayRuntimeSessionStatus === "running" - : ["running", "resuming"].includes(stream.runPhase)); + ["running", "resuming"].includes(displayTaskStatus); const hasTerminalTaskStatus = [ "completed", "done", @@ -973,12 +548,11 @@ const SuperAgent = () => { "canceled", "stopped", "suspended", - ].includes(displayTaskStatus) && !isSuspendedByInterrupt; + ].includes(displayTaskStatus); const isTerminalTask = isFailedTask || - (hasSuspendedRuntimeStatus && !isSuspendedByInterrupt) || + hasSuspendedRuntimeStatus || (!hasPersistedActiveThread && !isBodyGenerationRuntimeActive && hasTerminalTaskStatus) || - isTerminalRuntimeSession || (["failed", "completed"].includes(stream.runPhase) && !hasPendingInterruptSignal); const hasRunningTaskStatus = [ "queued", @@ -993,21 +567,7 @@ const SuperAgent = () => { "streaming", "resuming", "awaiting_interrupt", - ].includes(displayTaskStatus) || - [ - "queued", - "starting", - "started", - "running", - "pending", - "processing", - "generating", - "writing", - "active", - "streaming", - "resuming", - "awaiting_interrupt", - ].includes(displayRuntimeSessionStatus); + ].includes(displayTaskStatus); const hasActiveToolCall = stream.toolCalls.some(isActiveToolCall); const hasActiveToolCallSignal = !isTerminalTask && hasActiveToolCall; const hasStreamExecutionSignal = @@ -1015,14 +575,15 @@ const SuperAgent = () => { (stream.isLoading || hasRunningTaskStatus || hasActiveToolCallSignal); - // 本次发送后的本地 runPhase 优先于快照中的终态;终态只代表发送前的历史状态。 + // task.status 是唯一任务状态源;suspended 时本地运行信号不能恢复运行态。 const hasActiveStreamRunPhase = hasActiveSession && ["running", "awaiting_interrupt", "resuming"].includes(stream.runPhase) && - (!isTerminalTask || stream.isLocalRunActive); + !isTerminalTask; const isStreamRunning = !hasTerminalStreamRunPhase && !isFailedTask && + !hasSuspendedRuntimeStatus && (hasPersistedRunningThread || isBodyGenerationRuntimeActive || hasActiveStreamRunPhase || @@ -1083,12 +644,28 @@ const SuperAgent = () => { .filter(Boolean) ); hiddenCheckpointIds.forEach((checkpointId) => terminalCheckpointIds.add(checkpointId)); - const bodyTask = runtimeTask; - const bodyTaskStatus = displayTaskStatus || String(bodyTask.status || "").toLowerCase(); - const bodyTaskType = String(bodyTask.task_type || bodyTask.taskType || "").toLowerCase(); - const hasBodyTaskShape = - bodyTaskType === "artifact_build" || - (taskStage === TASK_STAGE.CONTENT && Number(bodyTask.total || 0) > 0); + const bodyTask = runtimeTask; + const bodyTaskStatus = displayTaskStatus || String(bodyTask.status || "").toLowerCase(); + const bodyTaskType = String(bodyTask.task_type || bodyTask.taskType || "").toLowerCase(); + const isBodyTaskTerminalStatus = [ + "completed", + "done", + "success", + "failed", + "error", + "cancelled", + "canceled", + "stopped", + "suspended", + ].includes(bodyTaskStatus); + const shouldUseTerminalStreamStatus = + terminalStreamStatus === "completed" && !isBodyTaskTerminalStatus; + const displayBodyTaskStatus = shouldUseTerminalStreamStatus + ? "completed" + : bodyTaskStatus; + const hasBodyTaskShape = + bodyTaskType === "artifact_build" || + (taskStage === TASK_STAGE.CONTENT && Number(bodyTask.total || 0) > 0); const isBodyGenerationActive = isBodyGenerationRuntimeActive && hasBodyTaskShape && @@ -1104,11 +681,10 @@ const SuperAgent = () => { "suspended", ].includes(bodyTaskStatus); const isSessionWaitingForInput = - WAITING_USER_SESSION_STATUSES.has(displayRuntimeSessionStatus) || hasPendingInterruptSignal; const canHideHistoricalGeneratedInterrupt = isBodyGenerationActive && - Boolean(runtimeSessionStatus) && + Boolean(taskStatus) && !isSessionWaitingForInput; const shouldHideCheckpointMessage = (item) => { if (isTerminalCheckpointMessage(item, terminalCheckpointIds)) return true; @@ -1263,9 +839,7 @@ const SuperAgent = () => { bodyTask.file_name || bodyTask.fileName || "投标文件正文", - status: displayTaskStatus || bodyTask.status, - session_status: - displayRuntimeSessionStatus || bodyTask.session_status || bodyTask.sessionStatus || "", + status: displayBodyTaskStatus || bodyTask.status, }; const isBodyTaskTerminalCardStatus = [ "completed", @@ -1278,18 +852,21 @@ const SuperAgent = () => { "cancelled", "canceled", "suspended", - ].includes(bodyTaskStatus) || [ - "completed", - "failed", - "error", - "cancelled", - "canceled", - "suspended", - ].includes(displayRuntimeSessionStatus); + ].includes(displayBodyTaskStatus); + const isSuspendedBodyTaskWithoutProgress = + displayTaskStatus === "suspended" && Number(bodyTask.progress || 0) <= 0; + const isCompletedBodyTaskWithoutProgress = + displayBodyTaskStatus === "completed" && Number(bodyTask.progress || 0) <= 0; + const shouldHideStaleTaskProgressMessage = + Boolean(taskStatus) && + hasTerminalTaskStatus && + isCompletedBodyTaskWithoutProgress; const shouldRenderBodyProgressCard = !hasActiveInterrupt && Boolean(activeSessionId) && hasBodyTaskShape && + !isSuspendedBodyTaskWithoutProgress && + !isCompletedBodyTaskWithoutProgress && (isBodyGenerationActive || isBodyTaskTerminalCardStatus); const derivedBodyProgressMessage = shouldRenderBodyProgressCard @@ -1307,13 +884,21 @@ const SuperAgent = () => { (item) => item?.type === "taskProgress" && String(item.sessionId || "") === String(activeSessionId || "") && - item.key === currentTaskProgressKey + item.key === currentTaskProgressKey && + !shouldHideStaleTaskProgressMessage ) : null; - const taskProgressMessages = [ - ...localTaskProgressMessages, - derivedBodyProgressMessage || currentTaskProgressMessage, - ].filter(Boolean); + const taskProgressMessages = isSuspendedBodyTaskWithoutProgress + ? [] + : [ + ...(shouldHideStaleTaskProgressMessage ? [] : localTaskProgressMessages), + derivedBodyProgressMessage || currentTaskProgressMessage, + ].filter(Boolean).filter((item) => { + const task = item?.task || {}; + const taskStatus = String(task.status || "").toLowerCase(); + const taskProgress = Number(task.progress || 0); + return !(taskStatus === "completed" && taskProgress <= 0); + }); const baseConversationMessages = [ ...(isStreamReadyForActiveSession ? visibleStreamMessages : []), ...unmatchedLocalUserFileMessages, @@ -1352,29 +937,7 @@ const SuperAgent = () => { : null; //[流式 loading 由 SuperAgentConversation 根据当前 assistant 消息统一渲染,避免额外占位消息重复显示] const conversationBaseMessages = conversationMessagesWithTaskProgress; - const streamTerminalStatus = [ - "completed", - "failed", - "cancelled", - "canceled", - "stopped", - "suspended", - ].includes(stream.runPhase) - ? stream.runPhase - : ""; - const conversationSessionStatus = streamTerminalStatus || displayRuntimeSessionStatus; - const normalizedConversationMessages = (messages = []) => messages.map((item) => { - if (item?.type !== "taskProgress" || !item.task) return item; - return { - ...item, - task: { - ...item.task, - session_status: - conversationSessionStatus || item.task.session_status || "", - }, - }; - }); - const conversationMessages = normalizedConversationMessages( + const conversationMessages = ( streamErrorMessage || taskFailureMessage ? [ ...conversationBaseMessages, @@ -1393,7 +956,6 @@ const SuperAgent = () => { bodyTaskForCard.total || 0, bodyTaskForCard.section_id || bodyTaskForCard.sectionId || "", bodyTaskForCard.file_name || bodyTaskForCard.fileName || "", - bodyTaskForCard.session_status || "", ].join("_"); const existingBodyTaskProgress = (Array.isArray(localConversationMessages) ? localConversationMessages @@ -1413,7 +975,6 @@ const SuperAgent = () => { existingBodyTask.total || 0, existingBodyTask.section_id || existingBodyTask.sectionId || "", existingBodyTask.file_name || existingBodyTask.fileName || "", - existingBodyTask.session_status || existingBodyTask.sessionStatus || "", ].join("_"); useEffect(() => { diff --git a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js index 1204aea..134384d 100644 --- a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js +++ b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js @@ -5,7 +5,7 @@ * 职责: * 1. 暴露标准 messages、toolCalls、interrupts、values 投影和运行控制方法。 * 2. 提交用户消息、响应 interrupt、停止运行和仅断开订阅。 - * 3. 订阅业务 custom channel,并通知容器刷新 workspace/sections。 + * 3. 维护当前 thread 的状态快照、interrupt 和生命周期同步。 * * 不负责: * 1. 创建业务 session、加载 workspace/sections 或管理业务会话。 @@ -19,7 +19,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStream } from "@langchain/react"; import { ProtocolSseTransportAdapter } from "@langchain/langgraph-sdk"; -import { ROOT_PUMP_CHANNELS } from "@langchain/langgraph-sdk/stream"; import { clearSuperAgentAccessToken, getSuperAgentFrameworkApiUrl, @@ -28,13 +27,6 @@ import { import { getSuperAgentErrorMessage } from "../utils/agentApiParse"; import { superAgentLog, superAgentWarn } from "../utils/debugLog"; -const BUSINESS_CHANNELS = [ - "custom", - "custom:generation", - "custom:section", - "custom:workspace", -]; - const TERMINAL_INTERRUPT_STATUSES = new Set([ "confirmed", "edited", @@ -252,29 +244,40 @@ class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter { latestState = null; onStateChange = null; stateRequestVersion = 0; + stateRequest = null; async getState() { - const requestVersion = ++this.stateRequestVersion; const requestedThreadId = String(this.threadId || "").trim(); - if (!requestedThreadId) { - this.latestState = null; - this.onStateChange?.(); - return null; - } - - // ProtocolSseTransportAdapter 的 getState() 会请求当前 thread 的 - // /state;其 fetchImpl 已绑定 fetchUseStreamTransport,因此会复用 - // 8005 代理、Agent JWT 和 401 换票逻辑。 - const state = await super.getState(); - // 旧 state 快照不能在切换 thread 后覆盖新会话。 if ( - requestVersion === this.stateRequestVersion && - String(this.threadId || "").trim() === requestedThreadId + this.stateRequest && + this.stateRequest.threadId === requestedThreadId ) { - this.latestState = state; - this.onStateChange?.(); + return this.stateRequest.promise; } - return state; + + const requestVersion = ++this.stateRequestVersion; + const statePromise = super.getState() + .then((state) => { + // getState 可能在切换 thread 后才返回;旧请求不能覆盖新 thread 的快照。 + if ( + requestVersion === this.stateRequestVersion && + String(this.threadId || "").trim() === requestedThreadId + ) { + this.latestState = state; + this.onStateChange?.(); + } + return state; + }) + .finally(() => { + if (this.stateRequest?.promise === statePromise) { + this.stateRequest = null; + } + }); + this.stateRequest = { + threadId: requestedThreadId, + promise: statePromise, + }; + return statePromise; } openEventStream(params) { @@ -383,13 +386,6 @@ const mergeAbortSignals = (signals = []) => { return controller.signal; }; -// 业务事件复用 useStream 已有的 root pump,不再额外建立页面级事件连接。 -// 将业务 channel 纳入 root pump,避免 useChannelEffect 为 custom channel -// 触发 SSE rotation,导致新旧 /events 短暂重叠并被服务端返回 429。 -BUSINESS_CHANNELS.forEach((channel) => { - if (!ROOT_PUMP_CHANNELS.includes(channel)) ROOT_PUMP_CHANNELS.push(channel); -}); - const getEventPayload = (event) => event?.data ?? event?.payload ?? @@ -397,148 +393,27 @@ const getEventPayload = (event) => event?.params?.data ?? event; -//[刷新回放]保留当前会话的消息快照,避免 hydrate 返回的旧快照覆盖 SSE 增量。 -const mergeStreamMessageSnapshots = (previousMessages = [], currentMessages = []) => { - const previous = Array.isArray(previousMessages) ? previousMessages : []; - const current = Array.isArray(currentMessages) ? currentMessages : []; - if (!previous.length) return current; - if (!current.length) return previous; - - const currentById = new Map( - current - .filter((message) => message?.id) - .map((message) => [String(message.id), message]) - ); - const merged = []; - const usedIds = new Set(); - - previous.forEach((message) => { - const messageId = String(message?.id || ""); - if (!messageId) return; - merged.push(currentById.get(messageId) || message); - usedIds.add(messageId); - }); - - current.forEach((message) => { - const messageId = String(message?.id || ""); - if (!messageId || usedIds.has(messageId)) return; - merged.push(message); - }); - - return merged; -}; - -//[刷新回放]直接消费 root messages 事件,保证网络层已有数据时页面同步更新。 -const applyMessageEventToState = (currentState, event) => { - const data = event?.params?.data || {}; - const eventType = String(data.event || ""); - const nextMessages = new Map(currentState.messages); - let activeMessageId = currentState.activeMessageId; - const messageId = String(data.id || activeMessageId || "").trim(); - if (!messageId) return currentState; - - const previousMessage = nextMessages.get(messageId) || { - id: messageId, - type: "ai", - role: "ai", - content: "", - tool_calls: [], - }; - const nextMessage = { - ...previousMessage, - tool_calls: Array.isArray(previousMessage.tool_calls) - ? [...previousMessage.tool_calls] - : [], - }; - - if (eventType === "message-start") { - activeMessageId = messageId; - nextMessage.role = data.role || nextMessage.role || "ai"; - nextMessage.type = nextMessage.role; - } +//[读取标准协议事件状态][event 无法判断时由 state.task.status 兜底] +const getEventTaskStatus = (event, payload = {}) => { + if (!["lifecycle", "values"].includes(event?.method)) return ""; - if (eventType === "content-block-delta") { - const delta = data.delta || {}; - if (delta.type === "text-delta") { - nextMessage.content = `${String(nextMessage.content || "")}${String(delta.text || "")}`; - } - if (delta.type === "tool-call-delta") { - const toolCallIndex = Number(data.index || 0); - const previousToolCall = nextMessage.tool_calls[toolCallIndex] || {}; - const deltaArgs = delta.args; - const nextArgs = - previousToolCall.args && - typeof previousToolCall.args === "object" && - deltaArgs && - typeof deltaArgs === "object" - ? { ...previousToolCall.args, ...deltaArgs } - : deltaArgs ?? previousToolCall.args; - nextMessage.tool_calls[toolCallIndex] = { - ...previousToolCall, - args: nextArgs, - status: "running", - }; - } - } - - if (eventType === "content-block-finish") { - const content = data.content || {}; - if (content.type === "text") { - nextMessage.content = content.text || nextMessage.content || ""; - } - if (content.type === "tool_call") { - const toolCallIndex = Number(data.index || 0); - nextMessage.tool_calls[toolCallIndex] = { - ...(nextMessage.tool_calls[toolCallIndex] || {}), - ...content, - status: "success", - }; - } - } - - nextMessages.set(messageId, nextMessage); - return { - messages: nextMessages, - activeMessageId, - }; -}; - -const RUNTIME_STATE_REFRESH_EVENT_TYPES = new Set([ - "generation.completed", - "generation.failed", - "generation.cancelled", - "generation.error", -]); + const explicitStatus = String( + payload?.task?.status || + payload?.values?.task?.status || + payload?.state?.task?.status || + payload?.state?.values?.task?.status || + payload?.status || + "" + ).trim().toLowerCase(); + if (explicitStatus) return explicitStatus; -const shouldRefreshRuntimeState = ({ - method = "", - payload = {}, - isResuming = false, -} = {}) => { - if (method === "messages") return isResuming; - if (method === "custom") { - return RUNTIME_STATE_REFRESH_EVENT_TYPES.has(String(payload.type || "")); - } - if (method === "lifecycle") { - return ["completed", "failed", "aborted"].includes( - String(payload.event || "") - ); - } - return false; -}; + if (event?.method !== "lifecycle") return ""; -const getEventDedupeKey = (event) => { - if (event?.event_id) return String(event.event_id); - const payload = getEventPayload(event) || {}; - return [ - payload.task_id || payload.taskId || "", - payload.node_id || payload.nodeId || "", - payload.version || "", - payload.type || event?.type || event?.event || "", - payload.progress ?? payload.task?.progress ?? "", - payload.done ?? payload.task?.done ?? "", - payload.section_id || payload.sectionId || payload.task?.section_id || "", - ].join(":"); + const lifecycleEvent = String(payload?.event || "").trim().toLowerCase(); + if (["started", "running"].includes(lifecycleEvent)) return "running"; + if (lifecycleEvent === "failed") return "failed"; + if (["completed", "aborted"].includes(lifecycleEvent)) return "completed"; + return ""; }; const normalizeStreamError = (error) => { @@ -563,41 +438,29 @@ const normalizeStreamError = (error) => { const useSuperAgentStream = ({ activeSessionId, userId: sessionUserId, - onBusinessInvalidation, onStreamError, }) => { - const handledEventKeysRef = useRef(new Set()); const interruptResumeRef = useRef({ active: false, running: false }); const pendingInterruptRef = useRef(false); const awaitingInterruptRef = useRef(false); const [runPhase, setRunPhase] = useState("idle"); + const [eventTaskStatus, setEventTaskStatus] = useState(""); const [isLocalRunActive, setIsLocalRunActive] = useState(false); const [isRespondingInterrupt, setIsRespondingInterrupt] = useState(false); - const [isBusinessRunning, setIsBusinessRunning] = useState(false); - const [runtimeCheckpointDeliveries, setRuntimeCheckpointDeliveries] = useState([]); const [runtimeInterrupts, setRuntimeInterrupts] = useState([]); const [dismissedInterruptIds, setDismissedInterruptIds] = useState(new Set()); - const runtimeStateRefreshTimerRef = useRef(null); - const runtimeStateRefreshPendingRef = useRef(false); const resolvedInterruptIdsRef = useRef(new Set()); const resolvedInterruptInteractionsRef = useRef(new Set()); const [currentInterruptId, setCurrentInterruptId] = useState(""); const [hydratedThreadId, setHydratedThreadId] = useState(""); const [transportStateRevision, setTransportStateRevision] = useState(0); const eventRequestControllersRef = useRef(new Map()); - const eventMessageStateRef = useRef({ - sessionId: "", - activeMessageId: "", - messages: new Map(), - }); - const [eventStreamMessages, setEventStreamMessages] = useState([]); const pendingSubmitThreadIdRef = useRef(""); const hydrationSessionRef = useRef({ sessionId: "", hasSeenLoading: false, }); const localRunActiveRef = useRef(false); - const localRunHasSeenLoadingRef = useRef(false); const activeSessionIdRef = useRef(""); activeSessionIdRef.current = activeSessionId; // Agent 请求只使用短时 Agent JWT;ai-center token 只用于 query api 换票。 @@ -625,6 +488,7 @@ const useSuperAgentStream = ({ }); return response; }, []); + const fetchUseStreamTransport = useCallback(async (...args) => { const [input, init = {}] = args; if (isUseStreamHistoryDiscoveryRequest(input)) { @@ -675,7 +539,7 @@ const useSuperAgentStream = ({ }; return transport; }, - [activeSessionId, defaultHeaders, fetchUseStreamTransport] + [defaultHeaders, fetchUseStreamTransport] ); // StreamController 在 hydrate 时会先通过 transport.getState() 取新 thread; // 自定义 adapter 需在该 effect 运行前同步绑定目标 thread,避免读到旧会话。 @@ -697,87 +561,20 @@ const useSuperAgentStream = ({ transport: eventTransport, }); - //[恢复中断后同步 thread state][避免 SSE 已收到但消息投影仍停留在旧快照] - const scheduleRuntimeStateRefresh = useCallback((delay = 120, force = false) => { - if (runtimeStateRefreshTimerRef.current != null) return; - if (!force && runtimeStateRefreshPendingRef.current) return; - if (!force) runtimeStateRefreshPendingRef.current = true; - - runtimeStateRefreshTimerRef.current = setTimeout(async () => { - runtimeStateRefreshTimerRef.current = null; - if (!String(eventTransport.threadId || "").trim()) { - runtimeStateRefreshPendingRef.current = false; - return; - } - try { - await eventTransport.getState().catch(() => undefined); - } finally { - runtimeStateRefreshPendingRef.current = false; - } - }, delay); - }, [eventTransport]); - - const handleBusinessEvent = useCallback((event) => { - const eventKey = getEventDedupeKey(event); - if (handledEventKeysRef.current.has(eventKey)) return; - handledEventKeysRef.current.add(eventKey); - const payload = getEventPayload(event) || {}; - const eventType = String(payload.type || ""); - if ([ - "generation.started", - "generation.progress", - "section.started", - "section.progress", - "section.updated", - "sections.updated", - "workspace.updated", - "sections.rewrite.requested", - ].includes(eventType)) { - setIsBusinessRunning(true); - awaitingInterruptRef.current = false; - setRunPhase("running"); - } - if ([ - "generation.completed", - "generation.failed", - "generation.cancelled", - "generation.error", - ].includes(eventType)) { - setIsBusinessRunning(false); - } - const envelope = payload.envelope; - if (payload.type === "workspace.snapshot" && envelope?.checkpoint_id) { - setRuntimeCheckpointDeliveries((current) => { - const next = current.filter( - (item) => item?.checkpoint_id !== envelope.checkpoint_id - ); - return [...next, envelope]; - }); - } - onBusinessInvalidation?.(payload, event); - }, [onBusinessInvalidation]); - const handleStreamError = useCallback((error) => { localRunActiveRef.current = false; setIsLocalRunActive(false); - localRunHasSeenLoadingRef.current = false; interruptResumeRef.current = { active: false, running: false }; pendingInterruptRef.current = false; awaitingInterruptRef.current = false; setRunPhase("failed"); setIsRespondingInterrupt(false); - setIsBusinessRunning(false); onStreamError?.(normalizeStreamError(error)); }, [onStreamError]); useEffect(() => { const sessionKey = String(activeSessionId || "").trim(); return () => { - if (runtimeStateRefreshTimerRef.current != null) { - clearTimeout(runtimeStateRefreshTimerRef.current); - runtimeStateRefreshTimerRef.current = null; - } - runtimeStateRefreshPendingRef.current = false; if (sessionKey) { const sessionController = eventRequestControllersRef.current.get(sessionKey); sessionController?.abort(); @@ -797,30 +594,20 @@ const useSuperAgentStream = ({ sessionId: String(activeSessionId || ""), hasSeenLoading: false, }; - handledEventKeysRef.current.clear(); localRunActiveRef.current = false; setIsLocalRunActive(false); - localRunHasSeenLoadingRef.current = false; interruptResumeRef.current = { active: false, running: false }; pendingInterruptRef.current = false; awaitingInterruptRef.current = false; setRunPhase("idle"); + setEventTaskStatus(""); setIsRespondingInterrupt(false); - setIsBusinessRunning(false); - setRuntimeCheckpointDeliveries([]); setRuntimeInterrupts([]); - runtimeStateRefreshPendingRef.current = false; setDismissedInterruptIds(new Set()); resolvedInterruptIdsRef.current.clear(); resolvedInterruptInteractionsRef.current.clear(); setCurrentInterruptId(""); setHydratedThreadId(""); - eventMessageStateRef.current = { - sessionId: String(activeSessionId || ""), - activeMessageId: "", - messages: new Map(), - }; - setEventStreamMessages([]); }, [activeSessionId]); useEffect(() => { @@ -849,27 +636,6 @@ const useSuperAgentStream = ({ transportStateRevision, ]); - const handleMessageEvent = useCallback((event) => { - if (event?.method !== "messages") return; - const namespace = event?.params?.namespace; - if (Array.isArray(namespace) && namespace.length > 0) return; - - const sessionId = String(activeSessionIdRef.current || "").trim(); - if (!sessionId) return; - if (eventMessageStateRef.current.sessionId !== sessionId) { - eventMessageStateRef.current = { - sessionId, - activeMessageId: "", - messages: new Map(), - }; - } - - const nextState = applyMessageEventToState(eventMessageStateRef.current, event); - if (nextState === eventMessageStateRef.current) return; - eventMessageStateRef.current = nextState; - setEventStreamMessages([...nextState.messages.values()]); - }, []); - const checkpointDeliveries = useMemo(() => { const deliveries = new Map(); const hydratedDeliveries = [ @@ -880,14 +646,13 @@ const useSuperAgentStream = ({ ? eventTransport.latestState.values.checkpoint_deliveries : []), ]; - [...hydratedDeliveries, ...runtimeCheckpointDeliveries].forEach((item) => { + hydratedDeliveries.forEach((item) => { if (!item?.checkpoint_id) return; deliveries.set(item.checkpoint_id, item); }); return [...deliveries.values()]; }, [ eventTransport.latestState, - runtimeCheckpointDeliveries, stream.values?.checkpoint_deliveries, transportStateRevision, ]); @@ -897,53 +662,36 @@ const useSuperAgentStream = ({ }, [handleStreamError, stream.error]); useEffect(() => { - if (!localRunActiveRef.current) return; - if (stream.isLoading) { - localRunHasSeenLoadingRef.current = true; - return; - } - if (!localRunHasSeenLoadingRef.current) return; - - localRunActiveRef.current = false; - localRunHasSeenLoadingRef.current = false; - setIsLocalRunActive(false); - setRunPhase((currentPhase) => - ["running", "resuming"].includes(currentPhase) ? "completed" : currentPhase - ); - setIsBusinessRunning(false); - setIsRespondingInterrupt(false); - }, [stream.isLoading]); - - useEffect(() => { + const listenerSessionId = String( + pendingSubmitThreadIdRef.current || activeSessionId || stream.threadId || "" + ).trim(); const isCurrentThread = - Boolean(activeSessionId) && - String(stream.threadId || "") === String(activeSessionId || ""); + Boolean(listenerSessionId) && + String(stream.threadId || "") === listenerSessionId; if (!isCurrentThread) return undefined; - const listenerSessionId = String(activeSessionId || ""); const currentThread = stream.getThread?.(); if (!currentThread) return undefined; return currentThread.onEvent((event) => { // 切换会话后旧 Thread 的监听器可能在清理前再收到一个事件, // 不能把旧会话的事件写入新会话的流式消息状态。 - if (activeSessionIdRef.current !== listenerSessionId) return; + const currentEventSessionId = String( + pendingSubmitThreadIdRef.current || activeSessionIdRef.current || "" + ).trim(); + if (currentEventSessionId !== listenerSessionId) return; superAgentLog("stream.event", "received", { threadId: listenerSessionId, eventType: event?.method || "unknown", lifecycleType: event?.params?.data?.event || "", }); - handleMessageEvent(event); const eventPayload = getEventPayload(event) || {}; - if (shouldRefreshRuntimeState({ - method: event?.method, - payload: eventPayload, - isResuming: interruptResumeRef.current.active, - })) { - const isTerminalEvent = - event?.method === "custom" || event?.method === "lifecycle"; - const refreshDelay = event?.method === "messages" ? 160 : 0; - scheduleRuntimeStateRefresh(refreshDelay, isTerminalEvent); + const nextEventTaskStatus = getEventTaskStatus(event, eventPayload); + if ( + nextEventTaskStatus && + !(event?.method === "lifecycle" && pendingInterruptRef.current) + ) { + setEventTaskStatus(nextEventTaskStatus); } if (event?.method === "input.requested") { const data = event.params?.data || {}; @@ -996,6 +744,7 @@ const useSuperAgentStream = ({ if (hasRealInterrupt) { localRunActiveRef.current = false; setIsLocalRunActive(false); + setEventTaskStatus("suspended"); awaitingInterruptRef.current = false; setRunPhase("waiting_interrupt"); } else { @@ -1014,24 +763,14 @@ const useSuperAgentStream = ({ // aborted 表示本轮已结束,不能让 started 留下的 running 状态继续生效。 setRunPhase(lifecycleEvent === "aborted" ? "completed" : lifecycleEvent); } - setIsBusinessRunning(false); if (interruptResumeRef.current.active) { interruptResumeRef.current = { active: false, running: false }; setIsRespondingInterrupt(false); } } } - if (!BUSINESS_CHANNELS.includes(event?.method)) return; - handleBusinessEvent(event); }); - }, [ - activeSessionId, - handleBusinessEvent, - handleMessageEvent, - scheduleRuntimeStateRefresh, - stream.threadId, - transportStateRevision, - ]); + }, [activeSessionId, stream.threadId, transportStateRevision]); const prepareForSessionSwitch = useCallback(() => { // 先让旧 thread 的事件监听失效。activeSessionId 在 React 下一次渲染 @@ -1043,13 +782,12 @@ const useSuperAgentStream = ({ setHydratedThreadId(""); localRunActiveRef.current = false; setIsLocalRunActive(false); - localRunHasSeenLoadingRef.current = false; interruptResumeRef.current = { active: false, running: false }; pendingInterruptRef.current = false; awaitingInterruptRef.current = false; setRunPhase("idle"); + setEventTaskStatus(""); setIsRespondingInterrupt(false); - setIsBusinessRunning(false); setRuntimeInterrupts([]); resolvedInterruptIdsRef.current.clear(); resolvedInterruptInteractionsRef.current.clear(); @@ -1086,12 +824,12 @@ const useSuperAgentStream = ({ if ((stream.isLoading && !isSwitchingThread) || isBlockedByInterrupt) { throw new Error("任务正在处理,请稍后再试"); } - if (stream.isThreadLoading && !isSwitchingThread) { + if (stream.isThreadLoading && !isSwitchingThread) { // 新建会话的 state 可能还未写入 root store。等待 hydrate 完成后再提交, // 避免 optimistic 消息被空快照或旧事件回放覆盖。 - await Promise.resolve(stream.hydrationPromise).catch(() => undefined); - } - // activeSessionId 的 React 状态更新可能晚于首次提交;先绑定 transport + await Promise.resolve(stream.hydrationPromise).catch(() => undefined); + } + // activeSessionId 的 React 状态更新可能晚于首次提交;先绑定 transport // 和事件请求所属会话,避免首轮响应仍落到旧 thread。 if (hydrationSessionRef.current.sessionId === targetThreadId) { hydrationSessionRef.current.hasSeenLoading = true; @@ -1099,8 +837,9 @@ const useSuperAgentStream = ({ pendingSubmitThreadIdRef.current = targetThreadId; eventTransport.setThreadId(targetThreadId); localRunActiveRef.current = true; + // 新一轮提交前清掉旧终态;后续 lifecycle/values event 会继续校正该状态。 + setEventTaskStatus("running"); setIsLocalRunActive(true); - localRunHasSeenLoadingRef.current = false; setRunPhase("running"); try { await stream.submit({ @@ -1125,10 +864,10 @@ const useSuperAgentStream = ({ const respondInterrupt = useCallback((response, options = {}) => { const interruptId = String(options.interruptId || "").trim(); localRunActiveRef.current = true; + // 恢复动作已经开始,不能让上一轮 completed 快照继续显示。 + setEventTaskStatus("running"); setIsLocalRunActive(true); - localRunHasSeenLoadingRef.current = false; interruptResumeRef.current = { active: true, running: false }; - runtimeStateRefreshPendingRef.current = false; pendingInterruptRef.current = false; awaitingInterruptRef.current = false; setRunPhase("resuming"); @@ -1150,7 +889,6 @@ const useSuperAgentStream = ({ interruptId: options.interruptId, namespace: options.namespace, }).then((result) => { - scheduleRuntimeStateRefresh(100); if (interruptId) { resolvedInterruptIdsRef.current.add(interruptId); const interactionKey = getInterruptInteractionKey( @@ -1187,18 +925,17 @@ const useSuperAgentStream = ({ setIsRespondingInterrupt(false); throw error; }); - }, [scheduleRuntimeStateRefresh, stream]); + }, [stream]); const stopRun = useCallback(async () => { localRunActiveRef.current = false; setIsLocalRunActive(false); - localRunHasSeenLoadingRef.current = false; interruptResumeRef.current = { active: false, running: false }; pendingInterruptRef.current = false; awaitingInterruptRef.current = false; setRunPhase("idle"); + setEventTaskStatus(""); setIsRespondingInterrupt(false); - setIsBusinessRunning(false); await stream.stop(); }, [stream]); @@ -1282,33 +1019,14 @@ const useSuperAgentStream = ({ isThreadReady, ]); - const hydratedTailIds = hydratedMessages - .slice(-3) - .map((item) => String(item?.id || "").trim()) - .filter(Boolean); - const rawStreamMessageIds = new Set( - rawStreamMessages - .map((item) => String(item?.id || "").trim()) - .filter(Boolean) - ); - const hydratedTaskStatus = String( - hydratedState?.values?.status || - hydratedState?.values?.session_status || - hydratedState?.values?.sessionStatus || - hydratedState?.values?.task?.session_status || - hydratedState?.values?.task?.sessionStatus || - hydratedState?.values?.task?.status || - "" - ).toLowerCase(); - const rawTaskStatus = String( - rawStreamValues.status || - rawStreamValues.session_status || - rawStreamValues.sessionStatus || - rawStreamValues.task?.session_status || - rawStreamValues.task?.sessionStatus || - rawStreamValues.task?.status || - "" - ).toLowerCase(); + const hydratedTaskStatus = String( + hydratedState?.values?.task?.status || + "" + ).toLowerCase(); + const rawTaskStatus = String( + rawStreamValues.task?.status || + "" + ).toLowerCase(); const hydratedCheckpointDeliveryCount = Array.isArray( hydratedState?.values?.checkpoint_deliveries ) @@ -1324,51 +1042,31 @@ const useSuperAgentStream = ({ hydratedTaskStatus !== rawTaskStatus || hydratedCheckpointDeliveryCount > rawCheckpointDeliveryCount); //[state 是当前 thread 的最新快照,SSE 重连可能先重放更早的 values] - // 消息和 task/value 的权威来源不能共用一个切换条件。 - // hydrate 返回空 messages 时,仍可能带有较新的 task 状态;此时只能更新 - // task/value,不能让空快照覆盖事件流已经收到的消息。 - const shouldPreferHydratedMessages = - hydratedMessages.length > 0 && - (rawStreamMessages.length < hydratedMessages.length || - (hydratedTailIds.length > 0 && - !hydratedTailIds.every((messageId) => rawStreamMessageIds.has(messageId)))); - const shouldPreferHydratedState = - shouldPreferHydratedRuntimeState || shouldPreferHydratedMessages; - const projectedMessages = shouldPreferHydratedMessages - ? hydratedMessages - : rawStreamMessages; - const streamMessages = mergeStreamMessageSnapshots( - projectedMessages, - isThreadBound && eventMessageStateRef.current.sessionId === currentSessionId - ? eventStreamMessages - : [] - ); + // task/value 仍允许使用更新的 hydrate 快照;会话消息统一使用 useStream + // 已完成增量与快照对账的根投影,避免业务层再次拼接 SSE 消息。 + const shouldPreferHydratedState = shouldPreferHydratedRuntimeState; + const streamMessages = rawStreamMessages; const streamToolCalls = isStreamProjectionReady ? stream.toolCalls : []; const streamValues = shouldPreferHydratedState ? hydratedState?.values || {} : rawStreamValues; const streamSessionStatus = String( - streamValues.status || - streamValues.session_status || - streamValues.sessionStatus || - streamValues.task?.session_status || - streamValues.task?.sessionStatus || - "" + eventTaskStatus || streamValues?.task?.status || "" ).toLowerCase(); const streamTask = streamValues?.task ? { ...streamValues.task, - ...(streamSessionStatus - ? { session_status: streamSessionStatus } - : {}), + ...(eventTaskStatus ? { status: eventTaskStatus } : {}), } : null; + const projectedStreamValues = + streamTask && eventTaskStatus + ? { ...streamValues, task: streamTask } + : streamValues; const persistedTaskStatus = String( streamSessionStatus || streamTask?.status || "" ).toLowerCase(); const isPersistedTaskActive = ACTIVE_TASK_STATUSES.has(persistedTaskStatus); - const hasPersistedNextNodes = - Array.isArray(hydratedState?.next) && hydratedState.next.length > 0; const hydratedInterrupts = [ ...(Array.isArray(hydratedState?.interrupts) ? hydratedState.interrupts @@ -1407,8 +1105,7 @@ const useSuperAgentStream = ({ ["waiting_interrupt", "awaiting_interrupt"].includes(runPhase); const isPersistedThreadActive = hasPersistedInterrupts || - isPersistedTaskActive || - hasPersistedNextNodes; + isPersistedTaskActive; const isPersistedExecutionActive = isPersistedThreadActive && !["suspended", "waiting_user", "awaiting_interrupt", "waiting_interrupt"].includes( @@ -1418,6 +1115,7 @@ const useSuperAgentStream = ({ const isPersistedSuspended = persistedTaskStatus === "suspended" && !hasPersistedInterrupts; + const isLocalExecutionActive = isLocalRunActive || isRespondingInterrupt; const isTerminalRunPhase = [ "completed", "aborted", @@ -1425,22 +1123,36 @@ const useSuperAgentStream = ({ "cancelled", "canceled", ].includes(runPhase); - const isPersistedExecutionActiveForUi = - isPersistedExecutionActive && !isTerminalRunPhase; + const shouldUsePersistedRunningPhase = + isPersistedExecutionActive && + isTerminalRunPhase && + !isLocalExecutionActive; const effectiveRunPhase = - isPersistedSuspended - ? "idle" - : shouldResetStaleWaitingPhase - ? "running" - : isPersistedExecutionActive && runPhase === "idle" - ? "running" - : runPhase; + isLocalExecutionActive && runPhase !== "idle" + ? runPhase + : isPersistedSuspended + ? "idle" + : shouldResetStaleWaitingPhase + ? "running" + : shouldUsePersistedRunningPhase + ? "running" + : isPersistedExecutionActive && runPhase === "idle" + ? "running" + : runPhase; + const isEffectiveTerminalRunPhase = [ + "completed", + "aborted", + "failed", + "cancelled", + "canceled", + ].includes(effectiveRunPhase); + const isPersistedExecutionActiveForUi = + isPersistedExecutionActive && !isEffectiveTerminalRunPhase; const isPersistedThreadActiveForUi = - isPersistedThreadActive && !isTerminalRunPhase; + (isPersistedExecutionActive || hasPersistedInterrupts) && + !isEffectiveTerminalRunPhase; const effectiveIsLocalRunActive = - isPersistedSuspended || isTerminalRunPhase ? false : isLocalRunActive; - const effectiveIsBusinessRunning = - isPersistedSuspended || isTerminalRunPhase ? false : isBusinessRunning; + isEffectiveTerminalRunPhase ? false : isLocalRunActive; const visibleInterrupts = useMemo(() => { if (!isStreamProjectionReady && !isInterruptProjectionReady) return []; @@ -1489,14 +1201,14 @@ const useSuperAgentStream = ({ effectiveRunPhase ); const effectiveIsLoading = - isTerminalRunPhase + isEffectiveTerminalRunPhase ? false - : isPersistedSuspended + : isPersistedSuspended && !isLocalExecutionActive ? isRespondingInterrupt : stream.isLoading || isRespondingInterrupt || - isBusinessRunning || - isRunPhaseActive || + isRunPhaseActive || + isLocalExecutionActive || isPersistedExecutionActiveForUi; return { @@ -1504,7 +1216,7 @@ const useSuperAgentStream = ({ toolCalls: streamToolCalls, interrupt: visibleInterrupts[0] || null, interrupts: visibleInterrupts, - values: streamValues, + values: projectedStreamValues, checkpointDeliveries: isStreamProjectionReady ? checkpointDeliveries : [], task: streamTask, sessionStatus: streamSessionStatus, @@ -1522,7 +1234,6 @@ const useSuperAgentStream = ({ visibleInterrupts.length > 0 || (effectiveRunPhase === "waiting_interrupt" && !isPersistedThreadActive), isAwaitingInterrupt: effectiveRunPhase === "awaiting_interrupt", - isBusinessRunning: effectiveIsBusinessRunning, isThreadLoading: stream.isThreadLoading, isThreadReady, isThreadProjectionReady: diff --git a/web/client/src/sections/superAgent/utils/workspace.js b/web/client/src/sections/superAgent/utils/workspace.js index 3430852..b82180f 100644 --- a/web/client/src/sections/superAgent/utils/workspace.js +++ b/web/client/src/sections/superAgent/utils/workspace.js @@ -794,6 +794,34 @@ const stripOutlineDisplayPrefix = (value) => .replace(/^第?[零〇一二三四五六七八九十百两]+(?:章|节|篇|部分)?[、..\s]*/, "") .trim(); +const MARKDOWN_TABLE_DIVIDER_REGEX = + /^\s*[|]?\s*:?-{3,}:?\s*([|]\s*:?-{3,}:?\s*)+[|]?\s*$/gm; + +const countMatches = (value, pattern) => + String(value || "").match(pattern)?.length || 0; + +const countSectionMedia = (content) => { + const text = String(content || ""); + const htmlImageCount = countMatches(text, /]*>/gi); + const markdownImageCount = countMatches(text, /!\[[^\]]*]\([^)]+\)/g); + const htmlMermaidCount = countMatches( + text, + /]*class=["'][^"']*\bmermaid\b[^"']*["'][^>]*>/gi + ); + const markdownMermaidCount = countMatches(text, /```mermaid\b[\s\S]*?```/gi); + const tableCount = + countMatches(text, /]*>/gi) || + countMatches(text, MARKDOWN_TABLE_DIVIDER_REGEX); + + return { + figureCount: + htmlImageCount + + markdownImageCount + + (htmlMermaidCount || markdownMermaidCount), + tableCount, + }; +}; + /** * Add UI-only display labels from the authoritative confirmed outline. * Canonical ids and titles remain untouched so section saves still address @@ -806,6 +834,7 @@ export const buildSectionDisplayHierarchy = (sectionList = [], headingTemplate = ); const parentIndexes = new Map(); const childCounts = new Map(); + const mediaCounters = new Map(); let topIndex = 0; //[根据模板生成层级编号前缀] @@ -841,6 +870,15 @@ export const buildSectionDisplayHierarchy = (sectionList = [], headingTemplate = if (parentTitle || parentId) { const parentKey = parentId || `title:${parentTitle}`; + const mediaCounter = mediaCounters.get(parentKey) || { + figure: 0, + table: 0, + }; + const mediaCount = countSectionMedia(section.content); + mediaCounters.set(parentKey, { + figure: mediaCounter.figure + mediaCount.figureCount, + table: mediaCounter.table + mediaCount.tableCount, + }); const startsParentGroup = !parentIndexes.has(parentKey); if (startsParentGroup) { topIndex += 1; @@ -853,6 +891,9 @@ export const buildSectionDisplayHierarchy = (sectionList = [], headingTemplate = return { ...section, id: sectionId, + chapterNumber: parentIndex, + figureStartIndex: mediaCounter.figure, + tableStartIndex: mediaCounter.table, parentGroupKey: parentKey, startsParentGroup, parentDisplayTitle: `${buildLevelPrefix(1, parentIndex)}${stripOutlineDisplayPrefix( @@ -865,9 +906,16 @@ export const buildSectionDisplayHierarchy = (sectionList = [], headingTemplate = } topIndex += 1; + const mediaCounter = mediaCounters.get(`section:${sectionId}`) || { + figure: 0, + table: 0, + }; return { ...section, id: sectionId, + chapterNumber: topIndex, + figureStartIndex: mediaCounter.figure, + tableStartIndex: mediaCounter.table, parentGroupKey: `section:${sectionId}`, startsParentGroup: true, parentDisplayTitle: "", diff --git a/web/tests/agentApiAdapter.test.mjs b/web/tests/agentApiAdapter.test.mjs index 54677af..f9aced5 100644 --- a/web/tests/agentApiAdapter.test.mjs +++ b/web/tests/agentApiAdapter.test.mjs @@ -24,6 +24,9 @@ const { isUploadIntentExpired, normalizeAttachmentUploadIntent, } = await import("../client/src/sections/superAgent/utils/attachmentUpload.js"); +const { buildStreamConversationMessages } = await import( + "../client/src/sections/superAgent/utils/streamMessage.js" +); test("new api table uses live agent paths", () => { assert.equal(superAgentServiceProxyMatch, "/_super-agent"); @@ -82,6 +85,29 @@ test("attachment upload intent follows the v2 three-stage payload", () => { assert.equal(isUploadIntentExpired(intent, Date.parse("2031-01-01T00:00:00Z")), true); }); +test("thread state attachment metadata becomes a user message file card", () => { + const messages = buildStreamConversationMessages([ + { + id: "user:1", + type: "human", + content: "分析数据", + additional_kwargs: { attachment_ids: ["attachment-1"] }, + }, + ], [], [], { + attachments: [ + { + id: "attachment-1", + file_name: "投标文件.docx", + message_id: "user:1", + status: "ready", + }, + ], + }); + + assert.equal(messages[0].files[0].attachmentId, "attachment-1"); + assert.equal(messages[0].files[0].name, "投标文件.docx"); +}); + test("agent errors keep request_id without exposing credentials", () => { const error = parseAgentErrorPayload( { code: "UNAUTHORIZED", message: "expired", request_id: "req_auth_1" }, @@ -128,7 +154,9 @@ test("adapter source exchanges login token for agent jwt and omits identity head assert.match(streamHook, /fetchAuthorizedStream/); assert.doesNotMatch(streamHook, /X-PEP-Token/); assert.match(resources, /completeAttachmentUpload/); - assert.match(resources, /listAttachments/); + assert.match(resources, /listAttachments/); + assert.match(resources, /listAllAttachments/); + assert.match(resources, /listAllPages\(\(pageQuery\) => listAttachments\(conversationId, pageQuery\), query\)/); assert.match(resources, /getArtifactChapter/); assert.match(resources, /returnMeta: true/); assert.match(workspace, /listArtifacts\(resourceConversationId/); @@ -139,8 +167,8 @@ test("adapter source exchanges login token for agent jwt and omits identity head assert.doesNotMatch(workspace, /updateSuperAgentSessionSectionDirect/); assert.match(chatHook, /createAttachmentUploadIntent\(conversationId/); assert.match(chatHook, /completeAttachmentUpload\(uploadIntent\.attachmentId\)/); - assert.match(chatHook, /listAttachments\(conversationId\)/); - assert.match(chatHook, /attachment\.status === "ready" && !attachment\.message_id/); + assert.match(chatHook, /listAllAttachments\(conversationId\)/); + assert.match(chatHook, /readyFiles\.filter\(\(attachment\) => !attachment\.messageId\)/); assert.match(chatHook, /handleCancelUpload/); assert.doesNotMatch(chatHook, /uploadSuperAgentFile/); assert.match(uploadUtils, /FormData/);