Browse Source

merge: 合并代码

master
Senor-Liu 1 month ago
parent
commit
0905246981
  1. 116
      api/app/utils/docxXmlExporter.js
  2. 108
      api/app/utils/plagiarismCheckService.js
  3. 5
      web/client/src/sections/aiTenderDocument/utils/exportToWord.js
  4. 96
      web/client/src/sections/superAgent/container/index.jsx
  5. 54
      web/client/src/sections/superAgent/hooks/useSuperAgentStream.js
  6. 70
      web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js
  7. 55
      web/client/src/sections/superAgent/utils/streamMessage.js

116
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\s*\/?>(?:<\/w:tab>)?/g, ' ')
.replace(/<w:br\s*\/?>(?:<\/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 `<w:p>${paragraphProperties}${runXml}</w:p>`;
};
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(/<w:pPr(?:\s[^>]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0];
const firstRunProperties = (paragraphXml.match(/<w:r(?:\s[^>]*)?>\s*(<w:rPr[\s\S]*?<\/w:rPr>)/) || ['', ''])[1];
const visibleText = escapeXml(String(text || ''));
return `<w:p>${paragraphProperties}<w:r>${firstRunProperties}<w:t xml:space="preserve">${visibleText}</w:t></w:r></w:p>`;
};
/**
* 按原 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(/<w:tbl(?:\s[^>]*)?>[\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(/<w:p(?:\s[^>]*)?>[\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(/<w:pPr(?:\s[^>]*)?>[\s\S]*?<\/w:pPr>/) || [''])[0];
return `<w:p>${paragraphProperties}${buildImageDrawingXml({ relationshipId, width, height })}</w:p>`;
} : 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(?:\s[^>]*)?>[^<]+<\/w:t>/.test(nextXml);
if (!hasVisibleText && !imageBlocks.length) return sourceBuffer;
zip.file(XML_PATH, nextXml);

108
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,

5
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 {

96
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();
};

54
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 || {}

70
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,

55
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();

Loading…
Cancel
Save