diff --git a/api/app/utils/docxXmlExporter.js b/api/app/utils/docxXmlExporter.js index 7e9c121..1b8d778 100644 --- a/api/app/utils/docxXmlExporter.js +++ b/api/app/utils/docxXmlExporter.js @@ -24,6 +24,19 @@ const getBlockText = block => String(block?.plainText || '') .replace(/\n+/g, ' ') .trim(); +const getStructureInfo = block => { + if (block?.structureInfo && typeof block.structureInfo === 'object') return block.structureInfo; + if (typeof block?.structureInfo === 'string') { + try { + const parsed = JSON.parse(block.structureInfo); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + return {}; + } + } + return {}; +}; + const normalizeXmlText = value => String(value || '') .replace(/(?:<\/w:tab>)?/g, ' ') .replace(/(?:<\/w:br>)?/g, '\n') @@ -166,32 +179,31 @@ const buildBlockXmlMapping = async ({ sourceBuffer, blocks }) => { * 编辑/删除后 blockIndex 可能已经重排,不能用它推断原 DOCX 段落位置。 * 用编辑前保存的原文校验旧映射,失效时按原文顺序重新定位,避免导出静默回退为原文件。 */ -const repairParagraphMappings = (documentXml, blocks) => { +const repairParagraphMappings = (documentXml, blocks, deletedParagraphIndexes = []) => { const paragraphs = getParagraphs(documentXml); + const deleted = new Set(deletedParagraphIndexes.map(Number)); 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 originalStructureInfo = getStructureInfo(block); + if (originalStructureInfo.xmlTableIndex !== undefined) { + return { ...block, structureInfo: originalStructureInfo }; + } + const structureInfo = { ...originalStructureInfo }; 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) { + // 原始 DOCX 没有增删段落时,段落索引是稳定的。即使当前文字已经被编辑, + // 也必须信任首次解析保存的位置,不能再按相似文字搜索,否则重复段落会错配。 + if (Number.isInteger(currentIndex) && currentIndex >= 0 + && currentIndex < paragraphs.length && !deleted.has(currentIndex)) { cursor = Math.max(cursor, currentIndex + 1); return block; } - const foundIndex = paragraphs.findIndex((paragraph, index) => index >= cursor && ( + const foundIndex = paragraphs.findIndex((paragraph, index) => index >= cursor && !deleted.has(index) && ( paragraph.text === anchorText || paragraph.text.includes(anchorText) || anchorText.includes(paragraph.text) @@ -300,6 +312,28 @@ const replaceParagraphWithHtmlRuns = (paragraphXml, html) => { return `${paragraphProperties}${runXml}`; }; +const hasBlockContentChanges = block => { + const structureInfo = getStructureInfo(block); + if (structureInfo.edited) return true; + const currentText = normalizeXmlText(block?.plainText || ''); + const originalText = structureInfo.originalPlainText === undefined + ? null + : normalizeXmlText(structureInfo.originalPlainText); + const currentHtml = String(block?.htmlContent || '').trim(); + const originalHtml = structureInfo.originalHtmlContent === undefined + ? null + : String(structureInfo.originalHtmlContent || '').trim(); + return (originalText !== null && originalText !== currentText) + || (originalHtml !== null && originalHtml !== currentHtml); +}; + +const replaceParagraphWithPlainText = (paragraphXml, text) => { + const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; + const firstRunProperties = (paragraphXml.match(/]*)?>\s*()/) || ['', ''])[1]; + const visibleText = escapeXml(String(text || '')); + return `${paragraphProperties}${firstRunProperties}${visibleText}`; +}; + /** * 按原 run 的文字长度分配新文本 * 无新增 Tiptap mark 时保留原 run 属性,避免普通编辑丢失原文格式 @@ -338,12 +372,13 @@ const replaceTextNodes = (paragraphXml, text) => { }; const replaceParagraphText = (paragraphXml, block) => { + if (!hasBlockContentChanges(block)) return paragraphXml; if (hasTiptapInlineFormatting(block?.htmlContent)) { return replaceParagraphWithHtmlRuns(paragraphXml, block.htmlContent); } const text = getBlockText(block); if (!text) return paragraphXml; - return replaceTextNodes(paragraphXml, text); + return replaceParagraphWithPlainText(paragraphXml, text); }; const replaceTableText = (tableXml, block) => { @@ -401,7 +436,7 @@ const replaceTableText = (tableXml, block) => { }); }; -const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], imageWriter = null) => { +const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], imageWriter = null, stats = {}) => { const editableBlocks = blocks .filter(block => ['paragraph', 'heading', 'table'].includes(String(block?.blockType || '').toLowerCase())) .sort((a, b) => Number(a.blockIndex || 0) - Number(b.blockIndex || 0)); @@ -411,20 +446,29 @@ const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], im 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; + if (!block) return tableXml; + const replacedTable = replaceTableText(tableXml, block); + if (replacedTable !== tableXml) stats.tableRewrites = (stats.tableRewrites || 0) + 1; + return replacedTable; }); let paragraphIndex = 0; return nextXml.replace(/]*)?>[\s\S]*?<\/w:p>/g, paragraphXml => { const currentIndex = paragraphIndex; paragraphIndex += 1; - if (deleted.has(currentIndex)) return ''; + if (deleted.has(currentIndex)) { + stats.deletedParagraphs = (stats.deletedParagraphs || 0) + 1; + 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); + if (!hasBlockContentChanges(block)) return paragraphXml; + const replacedParagraph = replaceParagraphText(paragraphXml, block); + if (replacedParagraph !== paragraphXml) stats.paragraphRewrites = (stats.paragraphRewrites || 0) + 1; + return replacedParagraph; }); }; @@ -432,12 +476,30 @@ const updateDocumentXml = (documentXml, blocks, deletedParagraphIndexes = [], im * 使用原始 DOCX 作为模板回写 block 文本 * 映射按解析块顺序进行,确保不编辑时原文件可以原样重新打包 */ -const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIndexes = [] }) => { +const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIndexes = [], logger = null }) => { 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 mappedBlocks = repairParagraphMappings(documentXml, blocks, deletedParagraphIndexes) + .map(block => ({ ...block, structureInfo: getStructureInfo(block) })); + const unmappedEditedBlocks = mappedBlocks.filter(block => { + const blockType = String(block?.blockType || '').toLowerCase(); + if (!['paragraph', 'heading', 'table'].includes(blockType)) return false; + const structureInfo = getStructureInfo(block); + const hasContentChange = structureInfo.edited || ( + structureInfo.originalPlainText !== undefined && + normalizeXmlText(structureInfo.originalPlainText) !== normalizeXmlText(block?.plainText || '') + ); + if (!hasContentChange) return false; + return blockType === 'table' + ? structureInfo.xmlTableIndex === undefined + : structureInfo.xmlParagraphIndex === undefined; + }); + if (unmappedEditedBlocks.length) { + const indexes = unmappedEditedBlocks.map(block => block.blockIndex).join(', '); + throw new Error(`导出失败:已修改段落未找到原文档位置(blockIndex: ${indexes}),已阻止导出原文件`); + } const hasXmlMapping = mappedBlocks.some(block => block?.structureInfo?.xmlParagraphIndex !== undefined || block?.structureInfo?.xmlTableIndex !== undefined @@ -471,7 +533,19 @@ const exportDocxFromOriginal = async ({ sourceBuffer, blocks, deletedParagraphIn const paragraphProperties = (paragraphXml.match(/]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0]; return `${paragraphProperties}${buildImageDrawingXml({ relationshipId, width, height })}`; } : null; - const nextXml = updateDocumentXml(documentXml, mappedBlocks, deletedParagraphIndexes, imageWriter); + const rewriteStats = {}; + const nextXml = updateDocumentXml(documentXml, mappedBlocks, deletedParagraphIndexes, imageWriter, rewriteStats); + logger?.info?.(`[plagiarism] export rewriteStats ${JSON.stringify(rewriteStats)}`); + const changedBlocks = mappedBlocks.filter(block => { + const structureInfo = getStructureInfo(block); + return structureInfo.edited || ( + structureInfo.originalPlainText !== undefined && + normalizeXmlText(structureInfo.originalPlainText) !== normalizeXmlText(block?.plainText || '') + ); + }); + if (changedBlocks.length && !rewriteStats.paragraphRewrites && !rewriteStats.tableRewrites && !imageBlocks.length) { + throw new Error(`导出失败:检测到 ${changedBlocks.length} 个已修改 block,但 DOCX 没有实际回写`); + } const hasVisibleText = /]*)?>[^<]+<\/w:t>/.test(nextXml); if (!hasVisibleText && !imageBlocks.length) return sourceBuffer; zip.file(XML_PATH, nextXml); diff --git a/api/app/utils/plagiarismCheckService.js b/api/app/utils/plagiarismCheckService.js index 4ec4a8f..6eed24b 100644 --- a/api/app/utils/plagiarismCheckService.js +++ b/api/app/utils/plagiarismCheckService.js @@ -6,6 +6,7 @@ const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const { randomUUID } = require('crypto'); +const { pathToFileURL } = require('url'); const qiniu = require('qiniu'); const superagent = require('superagent'); const mammoth = require('mammoth'); @@ -321,15 +322,25 @@ const isDocxZipBuffer = buffer => Buffer.isBuffer(buffer) && buffer.length >= 4 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 - .post(`${host}/uploadSecret`) - .set('Content-Type', 'application/octet-stream') - .send(buffer) - .responseType('arraybuffer') - .timeout({ response: 30000, deadline: 120000 }); - const decrypted = toBuffer(res.body); - if (!decrypted.length) throw new Error(`文件 ${fileName} 解密结果为空`); - return decrypted; + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + const res = await superagent + .post(`${host}/uploadSecret`) + .set('Content-Type', 'application/octet-stream') + .send(buffer) + .responseType('arraybuffer') + .timeout({ response: 30000, deadline: 120000 }); + const decrypted = toBuffer(res.body); + if (!decrypted.length) throw new Error(`文件 ${fileName} 解密结果为空`); + return decrypted; + } catch (error) { + lastError = error; + ctx.logger?.warn?.(`[plagiarism] decrypt attempt failed file=${fileName} attempt=${attempt} error=${error?.message || error}`); + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + throw new Error(`文件 ${fileName} 解密服务超时或不可用:${lastError?.message || lastError}`); }; const decryptIfNeeded = async (ctx, buffer, fileName) => { @@ -425,13 +436,39 @@ const writeTempFile = async (tempDir, fileName, buffer) => { return filePath; }; -const convertDocToDocx = async (tempDir, fileName, buffer) => { +const convertDocToDocx = async (ctx, tempDir, fileName, buffer) => { const inputPath = await writeTempFile(tempDir, fileName, buffer); - await runCommand(SOFFICE, ['--headless', '--convert-to', 'docx', '--outdir', tempDir, inputPath]); + // 每次转换使用独立 LibreOffice profile,避免并发/重复转换抢同一个用户 profile。 + const profilePath = pathToFileURL(path.join(tempDir, 'lo-profile')).href; + let outputBuffer; + for (let attempt = 1; attempt <= 2; attempt += 1) { + // 输入文件落盘后可能被迅软 DLP 透明加密,转换前兜底解密,确保 soffice 读到明文。 + const onDisk = await fsPromises.readFile(inputPath); + if (isEncryptedBuffer(onDisk)) { + ctx.logger?.warn?.(`[plagiarism] doc 输入文件被 DLP 加密,转换前解密 attempt=${attempt} file=${fileName}`); + await fsPromises.writeFile(inputPath, await decryptBuffer(ctx, onDisk, fileName)); + } + try { + await runCommand(SOFFICE, [`-env:UserInstallation=${profilePath}`, '--headless', '--convert-to', 'docx', '--outdir', tempDir, inputPath]); + break; + } catch (error) { + if (attempt >= 2) throw error; + ctx.logger?.warn?.(`[plagiarism] doc 转换失败,重试 attempt=${attempt} error=${error?.message || error}`); + } + } const baseName = path.basename(inputPath, path.extname(inputPath)); const outputPath = path.join(tempDir, `${baseName}.docx`); await fsPromises.access(outputPath); - return fsPromises.readFile(outputPath); + outputBuffer = await fsPromises.readFile(outputPath); + // 输出落盘后同样可能被迅软 DLP 透明加密:检测密文标记并解密后再校验,避免“输出不是有效 DOCX”。 + if (isEncryptedBuffer(outputBuffer)) { + ctx.logger?.warn?.(`[plagiarism] LibreOffice 输出被 DLP 加密,解密 file=${fileName}`); + outputBuffer = await decryptBuffer(ctx, outputBuffer, fileName); + } + if (!isDocxZipBuffer(outputBuffer)) { + throw new Error(`LibreOffice 转换 ${fileName} 失败:输出不是有效 DOCX`); + } + return outputBuffer; }; const uploadDocxImage = async (ctx, image) => { @@ -980,7 +1017,12 @@ const prepareSubmission = async (ctx) => { const file = files[index]; const meta = metas[index] || {}; const basic = ensureBasicFile(file, index); - const rawBuffer = await readFileBuffer(file); + let rawBuffer; + try { + rawBuffer = await readFileBuffer(file); + } catch (error) { + throw new Error(`文件 ${basic.originalName} 读取失败:${error?.message || error}`); + } let buffer = rawBuffer; let decrypted = false; if (basic.fileExt === 'docx' && !isDocxZipBuffer(buffer)) { @@ -1608,25 +1650,35 @@ const exportTaskFileDocx = async (ctx, { taskId, fileId }) => { }); if (!blocks.length) throw new Error('当前文件暂无可导出内容'); - if (String(file.fileExt || '').toLowerCase() === 'docx' && file.fileUrl) { + const sourceExt = String(file.fileExt || '').toLowerCase(); + if ((sourceExt === 'docx' || sourceExt === 'doc') && 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 文件为空'); + if (!sourceBuffer.length) throw new Error(`原始 ${sourceExt.toUpperCase()} 文件为空`); ctx.logger?.info?.(`[plagiarism] export source task=${taskId} file=${fileId} ${JSON.stringify(getBufferSignature(sourceBuffer))}`); - if (!isDocxZipBuffer(sourceBuffer)) { + const exportTempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'plagiarism-export-')); + try { + if (sourceExt === 'doc') { + // .doc 没有可直接编辑的 OOXML,固定先转换为 DOCX,再沿用原 DOCX XML 回写链路。 + sourceBuffer = await convertDocToDocx(ctx, exportTempDir, file.originalName, sourceBuffer); + } else if (!isDocxZipBuffer(sourceBuffer)) { ctx.logger?.warn?.(`[plagiarism] export source requires decrypt task=${taskId} file=${fileId}`); sourceBuffer = await decryptBuffer(ctx, sourceBuffer, file.originalName); + } + } finally { + await fsPromises.rm(exportTempDir, { recursive: true, force: true }).catch(() => { }); } 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 }); + ctx.logger?.info?.(`[plagiarism] export blocks task=${taskId} file=${fileId} deletedXmlParagraphIndexes=${JSON.stringify(deletedParagraphIndexes)} editedBlocks=${JSON.stringify(blocks.filter(block => block?.structureInfo?.edited).map(block => ({ blockIndex: block.blockIndex, xmlParagraphIndex: block.structureInfo?.xmlParagraphIndex, xmlTableIndex: block.structureInfo?.xmlTableIndex, originalPlainText: String(block.structureInfo?.originalPlainText || '').slice(0, 40), plainText: String(block.plainText || '').slice(0, 40) })))}`); + const buffer = await exportDocxFromOriginal({ sourceBuffer, blocks, deletedParagraphIndexes, logger: ctx.logger }); return { fileName: `${String(file.originalName || 'document').replace(/\.[^.]+$/, '') || 'document'}-编辑版.docx`, buffer, @@ -1649,11 +1701,19 @@ const parseOneFile = async (ctx, taskId, item) => { parseError: null, updatedAt: now(), }, { where: { id: fileId } }); + let conversionTempDir = null; try { - let blocks = await parseDocumentToBlocks(ctx, item); + let parseItem = item; + if (item.fileExt === 'doc') { + conversionTempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'plagiarism-doc-')); + const convertedBuffer = await convertDocToDocx(ctx, conversionTempDir, item.originalName, item.buffer); + // 后续解析和 XML 映射都使用这份转换结果,避免两次转换造成结构不一致。 + parseItem = { ...item, buffer: convertedBuffer, fileExt: 'docx' }; + } + let blocks = await parseDocumentToBlocks(ctx, parseItem); if (!blocks.length) throw new Error('未解析到有效文本内容'); - if (item.fileExt === 'docx') { - blocks = await buildBlockXmlMapping({ sourceBuffer: item.buffer, blocks }); + if (parseItem.fileExt === 'docx') { + blocks = await buildBlockXmlMapping({ sourceBuffer: parseItem.buffer, blocks }); } const rows = blocks.map(block => ({ taskId, @@ -1681,12 +1741,19 @@ const parseOneFile = async (ctx, taskId, item) => { await transaction.rollback(); throw error; } + if (conversionTempDir) { + await fsPromises.rm(conversionTempDir, { recursive: true, force: true }).catch(() => { }); + } } catch (error) { + if (conversionTempDir) { + await fsPromises.rm(conversionTempDir, { recursive: true, force: true }).catch(() => { }); + } await models.PlagiarismCheckFiles.update({ parseStatus: 'parse_failed', parseError: error?.message || String(error), updatedAt: now(), }, { where: { id: fileId } }); + ctx.logger?.error?.(`[plagiarism] parse failed task=${taskId} file=${fileId} name=${item.originalName}: ${error?.stack || error}`); } }; @@ -1707,6 +1774,7 @@ module.exports = { STATUS_TEXT, blocksFromHtml, blocksFromPlainText, + convertDocToDocx, createTask, findBlockMatches, generateDeduplicatedBlock, diff --git a/web/client/src/sections/aiTenderDocument/utils/exportToWord.js b/web/client/src/sections/aiTenderDocument/utils/exportToWord.js index b1b7aa1..7d8aeb7 100644 --- a/web/client/src/sections/aiTenderDocument/utils/exportToWord.js +++ b/web/client/src/sections/aiTenderDocument/utils/exportToWord.js @@ -3026,6 +3026,7 @@ export const exportToWord = async ( settings, filename = "document.docx", onBlobReady, + options = {}, ) => { console.log(content, "content"); console.log(settings, "settings"); @@ -4010,7 +4011,9 @@ export const exportToWord = async ( // 生成并下载文件 const blob = await Packer.toBlob(doc); - FileSaver.saveAs(blob, filename); + if (options.download !== false) { + FileSaver.saveAs(blob, filename); + } let resourceLibraryError = null; if (typeof onBlobReady === "function") { try { diff --git a/web/client/src/sections/superAgent/container/index.jsx b/web/client/src/sections/superAgent/container/index.jsx index d295919..382d904 100644 --- a/web/client/src/sections/superAgent/container/index.jsx +++ b/web/client/src/sections/superAgent/container/index.jsx @@ -161,6 +161,10 @@ const SuperAgent = () => { const contentWorkspaceRefreshKeyRef = useRef(""); const contentWorkspaceRefreshAtRef = useRef(0); const contentWorkspaceRefreshInFlightRef = useRef(false); + const uploadSessionFileToResourceLibraryRef = useRef(null); + const resourceUploadInFlightRef = useRef(new Set()); + const resourceUploadedTaskKeysRef = useRef(new Set()); + const bodyTaskSnapshotRef = useRef({ sessionId: "", task: null }); const checkpointSubmittingIdsRef = useRef(new Set()); const activeSessionRestoredRef = useRef(false); const transientPublishUiIdsRef = useRef(new Set()); @@ -428,12 +432,15 @@ const SuperAgent = () => { loadRegistrySection, handleUseFullScoreForWorkspace, handleDownloadSessionFile, + uploadSessionFileToResourceLibrary, handleFormFieldChange, openSessionContentWorkspace, refreshSessionContentWorkspace, syncSessionProgressSections, } = workspace; + uploadSessionFileToResourceLibraryRef.current = uploadSessionFileToResourceLibrary; + const handleProtocolError = useCallback((error) => { message.error(getSuperAgentErrorMessage(error, "智能体服务暂不可用")); }, []); @@ -468,6 +475,23 @@ const SuperAgent = () => { (!rawActiveStreamTaskId || rawActiveStreamTaskId === String(activeSessionId || "")) ? rawActiveStreamTask : null; + useEffect(() => { + const sessionId = String(activeSessionId || ""); + if (bodyTaskSnapshotRef.current.sessionId !== sessionId) { + bodyTaskSnapshotRef.current = { sessionId, task: null }; + } + if (!activeStreamTask) return; + + const taskType = String( + activeStreamTask.task_type || activeStreamTask.taskType || "" + ).toLowerCase(); + if (taskType !== "artifact_build") return; + + bodyTaskSnapshotRef.current = { + sessionId, + task: activeStreamTask, + }; + }, [activeSessionId, activeStreamTask]); const activeStreamInterrupts = streamReadyForActiveSession ? stream.interrupts || [] : []; const activeStreamCheckpointDeliveries = streamReadyForActiveSession ? stream.checkpointDeliveries || [] @@ -644,7 +668,11 @@ const SuperAgent = () => { .filter(Boolean) ); hiddenCheckpointIds.forEach((checkpointId) => terminalCheckpointIds.add(checkpointId)); - const bodyTask = runtimeTask; + const cachedBodyTask = + bodyTaskSnapshotRef.current.sessionId === String(activeSessionId || "") + ? bodyTaskSnapshotRef.current.task + : null; + const bodyTask = activeStreamTask || cachedBodyTask || {}; const bodyTaskStatus = displayTaskStatus || String(bodyTask.status || "").toLowerCase(); const bodyTaskType = String(bodyTask.task_type || bodyTask.taskType || "").toLowerCase(); const isBodyTaskTerminalStatus = [ @@ -1462,6 +1490,72 @@ const SuperAgent = () => { return undefined; }, [latestArtifactProgressKey]); + useEffect(() => { + if (!latestArtifactProgressMessage) return undefined; + + const sessionId = String(latestArtifactProgressMessage.sessionId || "").trim(); + const task = latestArtifactProgressMessage.task || {}; + const progress = Number(task.progress || 0); + if (!sessionId || progress < 100) return undefined; + + const taskId = String(task.task_id || task.taskId || "").trim(); + const uploadKey = [ + sessionId, + taskId || latestArtifactProgressKey, + progress, + ].join("_"); + if ( + resourceUploadInFlightRef.current.has(uploadKey) || + resourceUploadedTaskKeysRef.current.has(uploadKey) + ) { + return undefined; + } + + resourceUploadInFlightRef.current.add(uploadKey); + const upload = async () => { + try { + const result = await uploadSessionFileToResourceLibraryRef.current?.( + sessionId, + { taskId }, + ); + if (result?.success && !result?.resourceLibraryError) { + resourceUploadedTaskKeysRef.current.add(uploadKey); + superAgentLog("container.resource_library", "upload.done", { + sessionId, + taskId, + uploadKey, + }); + } else if (result?.resourceLibraryError) { + superAgentWarn("container.resource_library", "upload.failed", { + sessionId, + taskId, + uploadKey, + error: result.resourceLibraryError, + }); + } else if (!result?.skipped) { + superAgentWarn("container.resource_library", "upload.failed", { + sessionId, + taskId, + uploadKey, + error: result?.message || "保存到资源库失败", + }); + } + } catch (error) { + superAgentWarn("container.resource_library", "upload.error", { + sessionId, + taskId, + uploadKey, + error: error?.message, + }); + } finally { + resourceUploadInFlightRef.current.delete(uploadKey); + } + }; + + upload(); + return undefined; + }, [latestArtifactProgressKey]); + const handleStopAnswer = () => { return handleStopAndCancel(); }; diff --git a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js index 134384d..20f6f2f 100644 --- a/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js +++ b/web/client/src/sections/superAgent/hooks/useSuperAgentStream.js @@ -26,6 +26,7 @@ import { } from "../utils/agentApi"; import { getSuperAgentErrorMessage } from "../utils/agentApiParse"; import { superAgentLog, superAgentWarn } from "../utils/debugLog"; +import { orderStreamMessages } from "../utils/streamMessage"; const TERMINAL_INTERRUPT_STATUSES = new Set([ "confirmed", @@ -462,6 +463,13 @@ const useSuperAgentStream = ({ }); const localRunActiveRef = useRef(false); const activeSessionIdRef = useRef(""); + const completionStateRefreshRef = useRef({ threadId: "", requested: false }); + const messageOrderRef = useRef({ + threadId: "", + orderById: new Map(), + anonymousOrder: new WeakMap(), + nextOrder: 0, + }); activeSessionIdRef.current = activeSessionId; // Agent 请求只使用短时 Agent JWT;ai-center token 只用于 query api 换票。 const defaultHeaders = useMemo( @@ -604,6 +612,10 @@ const useSuperAgentStream = ({ setIsRespondingInterrupt(false); setRuntimeInterrupts([]); setDismissedInterruptIds(new Set()); + completionStateRefreshRef.current = { + threadId: String(activeSessionId || ""), + requested: false, + }; resolvedInterruptIdsRef.current.clear(); resolvedInterruptInteractionsRef.current.clear(); setCurrentInterruptId(""); @@ -754,6 +766,24 @@ const useSuperAgentStream = ({ } } if (["completed", "failed", "aborted"].includes(lifecycleEvent)) { + if (lifecycleEvent === "completed") { + const refreshStateRecord = completionStateRefreshRef.current; + if ( + refreshStateRecord.threadId !== listenerSessionId || + !refreshStateRecord.requested + ) { + completionStateRefreshRef.current = { + threadId: listenerSessionId, + requested: true, + }; + eventTransport.getState().catch((error) => { + superAgentLog("stream.state", "completion_refresh_failed", { + sessionId: listenerSessionId, + error: error?.message || error, + }); + }); + } + } if (pendingInterruptRef.current) { setRunPhase("waiting_interrupt"); } else { @@ -770,7 +800,7 @@ const useSuperAgentStream = ({ } } }); - }, [activeSessionId, stream.threadId, transportStateRevision]); + }, [activeSessionId, eventTransport, stream.threadId, transportStateRevision]); const prepareForSessionSwitch = useCallback(() => { // 先让旧 thread 的事件监听失效。activeSessionId 在 React 下一次渲染 @@ -837,6 +867,10 @@ const useSuperAgentStream = ({ pendingSubmitThreadIdRef.current = targetThreadId; eventTransport.setThreadId(targetThreadId); localRunActiveRef.current = true; + completionStateRefreshRef.current = { + threadId: targetThreadId, + requested: false, + }; // 新一轮提交前清掉旧终态;后续 lifecycle/values event 会继续校正该状态。 setEventTaskStatus("running"); setIsLocalRunActive(true); @@ -864,6 +898,10 @@ const useSuperAgentStream = ({ const respondInterrupt = useCallback((response, options = {}) => { const interruptId = String(options.interruptId || "").trim(); localRunActiveRef.current = true; + completionStateRefreshRef.current = { + threadId: String(activeSessionId || ""), + requested: false, + }; // 恢复动作已经开始,不能让上一轮 completed 快照继续显示。 setEventTaskStatus("running"); setIsLocalRunActive(true); @@ -925,7 +963,7 @@ const useSuperAgentStream = ({ setIsRespondingInterrupt(false); throw error; }); - }, [stream]); + }, [activeSessionId, stream]); const stopRun = useCallback(async () => { localRunActiveRef.current = false; @@ -1045,7 +1083,17 @@ const useSuperAgentStream = ({ // task/value 仍允许使用更新的 hydrate 快照;会话消息统一使用 useStream // 已完成增量与快照对账的根投影,避免业务层再次拼接 SSE 消息。 const shouldPreferHydratedState = shouldPreferHydratedRuntimeState; - const streamMessages = rawStreamMessages; + const streamMessages = useMemo(() => { + const threadId = String(currentSessionId || "").trim(); + const orderState = messageOrderRef.current; + if (orderState.threadId !== threadId) { + orderState.threadId = threadId; + orderState.orderById = new Map(); + orderState.anonymousOrder = new WeakMap(); + orderState.nextOrder = 0; + } + return orderStreamMessages(rawStreamMessages, orderState); + }, [currentSessionId, rawStreamMessages]); const streamToolCalls = isStreamProjectionReady ? stream.toolCalls : []; const streamValues = shouldPreferHydratedState ? hydratedState?.values || {} diff --git a/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js b/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js index 56f365d..bbe32d1 100644 --- a/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js +++ b/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js @@ -24,6 +24,7 @@ import { listArtifacts, updateArtifactChapter, } from "../actions/agentResources"; +import { uploadResourceFile } from "../actions/resourceLibrary"; import { defaultCatalog, defaultScoreItems, TASK_STAGE } from "../constants"; import { exportToWord } from "../../aiTenderDocument/utils/exportToWord"; import { @@ -1721,6 +1722,74 @@ const useSuperAgentWorkspace = ({ } }; + /** + * 功能:将已完成的标书正文导出为 Word 并保存到资源库文件分类。 + * 使用场景:正文生成进度达到 100 时自动入库。 + * 入参:sessionId,会话 ID;taskId,正文生成任务 ID。 + * 返回:导出和上传结果。 + * 注意:自动入库只生成并上传文件,不触发浏览器下载。 + */ + const uploadSessionFileToResourceLibrary = async ( + sessionId, + { headingTemplate, taskId } = {}, + ) => { + if (!sessionId) { + return { success: false, message: "缺少会话 ID,无法保存标书" }; + } + + try { + let exportContent = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + const contentList = await loadSessionContentList({ + sessionId, + fallbackSections: + workspaceData?.sessionId === sessionId ? workspaceBodyList : [], + forceFresh: true, + }); + exportContent = buildBodyExportContent( + buildSectionDisplayHierarchy(contentList, headingTemplate), + ); + const hasExportableContent = exportContent.some( + (item) => item.type === "text" && String(item.text || "").trim(), + ); + if (hasExportableContent || attempt === 2) break; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + const hasExportableContent = exportContent.some( + (item) => item.type === "text" && String(item.text || "").trim(), + ); + if (!hasExportableContent) { + return { success: false, skipped: true, message: "暂无可导出的正文内容" }; + } + + const fileName = buildTenderExportFileName(); + return await exportToWord( + exportContent, + createDefaultBodyExportSettings(), + fileName, + async (blob, finalFileName) => { + const formData = new FormData(); + formData.append("fileName", finalFileName); + formData.append("sessionId", String(sessionId)); + if (taskId) { + formData.append("taskId", String(taskId)); + } + formData.append("file", blob, finalFileName); + await uploadResourceFile(formData); + }, + { download: false }, + ); + } catch (error) { + superAgentError("workspace.resource_library", "upload.error", { + sessionId, + taskId, + error, + }); + return { success: false, message: error?.message || "保存到资源库失败" }; + } + }; + const openSessionContentWorkspace = async ({ sessionId, task, @@ -1842,6 +1911,7 @@ const useSuperAgentWorkspace = ({ handleFormFieldChange, handleUseFullScoreForWorkspace, handleDownloadSessionFile, + uploadSessionFileToResourceLibrary, openSessionStateWorkspace, openSessionContentWorkspace, refreshSessionContentWorkspace, diff --git a/web/client/src/sections/superAgent/utils/streamMessage.js b/web/client/src/sections/superAgent/utils/streamMessage.js index c3affcc..314d1f7 100644 --- a/web/client/src/sections/superAgent/utils/streamMessage.js +++ b/web/client/src/sections/superAgent/utils/streamMessage.js @@ -47,12 +47,9 @@ const normalizeMessageContent = (content) => { const text = readTextContent(content); const lines = text.split(/\r?\n/); const fileUrls = lines.filter(isUploadedFileUrl); - const fileNames = fileUrls.map(getUploadedFileName).filter(Boolean); - const messageText = lines.filter((line) => !isUploadedFileUrl(line)).join("\n").trim(); return { - // URL 继续放入 files 供附件 tag 使用;正文文本只展示解码后的文件名, - // 避免把带 token 的上传地址直接展示给用户。 - content: [...fileNames, messageText].filter(Boolean).join("\n"), + // URL 继续放入 files 供附件 tag 使用,正文不重复展示文件名。 + content: lines.filter((line) => !isUploadedFileUrl(line)).join("\n").trim(), files: fileUrls.map((url) => ({ id: url, name: getUploadedFileName(url), @@ -145,6 +142,54 @@ export const isActiveToolCall = (toolCall = {}) => { return ACTIVE_TOOL_STATUSES.has(status); }; +/** + * 功能:保持同一 thread 的流式消息按首次出现顺序展示。 + * 使用场景:暂停后恢复、刷新或 SSE 重连时,消息可能先收到新一轮增量, + * 再收到包含旧消息的 values 快照。 + * 入参:当前消息数组,以及当前 thread 的顺序状态对象。 + * 返回:不改变原数组的稳定有序消息数组。 + * 注意:有 id 的消息使用 id 对账;无 id 的消息只在当前数组内保持原顺序。 + */ +export const orderStreamMessages = (messages = [], orderState = {}) => { + const sourceMessages = Array.isArray(messages) ? messages : []; + const orderById = orderState.orderById || new Map(); + const anonymousOrder = orderState.anonymousOrder || new WeakMap(); + let nextOrder = Number(orderState.nextOrder || 0); + + const indexedMessages = sourceMessages.map((message, index) => { + const messageId = String(message?.id || "").trim(); + let order = null; + if (messageId) { + if (!orderById.has(messageId)) { + orderById.set(messageId, nextOrder); + nextOrder += 1; + } + order = orderById.get(messageId); + } else if (message && typeof message === "object") { + if (!anonymousOrder.has(message)) { + anonymousOrder.set(message, nextOrder); + nextOrder += 1; + } + order = anonymousOrder.get(message); + } + + return { message, index, order }; + }); + + orderState.orderById = orderById; + orderState.anonymousOrder = anonymousOrder; + orderState.nextOrder = nextOrder; + + return indexedMessages + .sort((left, right) => { + if (left.order === null || right.order === null) { + return left.index - right.index; + } + return left.order - right.order || left.index - right.index; + }) + .map((item) => item.message); +}; + //[normalizeToolCallStatus][统一 SDK tool call 的 state/status 状态] const normalizeToolCallStatus = (status, result) => { const normalizedStatus = String(status || "").trim().toLowerCase();