Browse Source

merge: 合并代码

master
Senor-Liu 1 month ago
parent
commit
271b8ceaa3
  1. 16
      api/app/controllers/plagiarismCheck.js
  2. 46
      api/app/controllers/v2Tender.js
  3. 488
      api/app/utils/docxXmlExporter.js
  4. 137
      api/app/utils/plagiarismCheckService.js
  5. 37
      web/client/src/sections/aiTenderDocument/components/DiagramEditor.jsx
  6. 39
      web/client/src/sections/aiTenderDocument/components/StrongTipTap.jsx
  7. 3
      web/client/src/sections/aiTenderDocument/container/Index.jsx
  8. 155
      web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.jsx
  9. 59
      web/client/src/sections/aiTenderDocument/container/aiPlagiarismCheck.module.less
  10. 10
      web/client/src/sections/aiTenderDocument/container/index.module.less
  11. 3
      web/client/src/sections/aiTenderDocument/utils/exportToWord.js
  12. 97
      web/client/src/sections/aiTenderDocument/utils/mermaidSanitizer.js
  13. 4
      web/client/src/sections/superAgent/components/SuperAgentWorkspace.jsx
  14. 16
      web/client/src/sections/superAgent/components/TaskProgressCard.jsx
  15. 4
      web/client/src/sections/superAgent/components/workspace/TreeMarkdownWorkspace.jsx
  16. 8
      web/client/src/sections/superAgent/constants.js
  17. 557
      web/client/src/sections/superAgent/container/index.jsx
  18. 501
      web/client/src/sections/superAgent/hooks/useSuperAgentStream.js
  19. 48
      web/client/src/sections/superAgent/utils/workspace.js
  20. 32
      web/tests/agentApiAdapter.test.mjs

16
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;

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

488
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const getBlockText = block => String(block?.plainText || '')
.replace(/\r/g, '')
.replace(/\n+/g, ' ')
.trim();
const normalizeXmlText = value => String(value || '')
.replace(/<w:tab\s*\/?>(?:<\/w:tab>)?/g, ' ')
.replace(/<w:br\s*\/?>(?:<\/w:br>)?/g, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/\s+/g, '')
.trim();
const getParagraphs = documentXml => {
const paragraphs = [];
documentXml.replace(/<w:p(?:\s[^>]*)?>[\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(/&nbsp;/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(/<w:tc(?:\s[^>]*)?>[\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 `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Mermaid diagram"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="diagram.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${relationshipId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
};
const appendImageRelationship = (relationships, relationshipId, target) => {
const relation = `<Relationship Id="${relationshipId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${target}"/>`;
if (relationships.includes('</Relationships>')) {
return relationships.replace('</Relationships>', `${relation}</Relationships>`);
}
return `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relation}</Relationships>`;
};
const registerImageContentType = (contentTypes, extension, mimeType) => {
if (new RegExp(`<Default[^>]+Extension="${extension}"`, 'i').test(contentTypes)) return contentTypes;
const node = `<Default Extension="${extension}" ContentType="${mimeType}"/>`;
return contentTypes.replace('</Types>', `${node}</Types>`);
};
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(/<w:tbl(?:\s[^>]*)?>[\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('<w:b/>');
if (marks.italic) properties.push('<w:i/>');
if (marks.underline) properties.push('<w:u w:val="single"/>');
if (marks.strike) properties.push('<w:strike/>');
if (marks.color) properties.push(`<w:color w:val="${marks.color}"/>`);
if (marks.fontFamily) {
const font = escapeXml(marks.fontFamily.replace(/["']/g, '').split(',')[0].trim());
if (font) properties.push(`<w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="${font}"/>`);
}
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(`<w:sz w:val="${halfPoints}"/><w:szCs w:val="${halfPoints}"/>`);
}
}
}
return properties.length ? `<w:rPr>${properties.join('')}</w:rPr>` : '';
};
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(/<w:pPr(?:\s[^>]*)?>[\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 ? `<w:t xml:space="preserve">${escapeXml(part)}</w:t>` : '';
return index === 0 ? line : `<w:br/>${line}`;
}).join('');
return `<w:r>${buildRunProperties(run.marks)}${content}</w:r>`;
}).join('');
return `<w:p>${paragraphProperties}${runXml}</w:p>`;
};
/**
* 按原 run 的文字长度分配新文本
* 无新增 Tiptap mark 时保留原 run 属性,避免普通编辑丢失原文格式
*/
const replaceTextNodes = (paragraphXml, text) => {
const runs = [];
paragraphXml.replace(/<w:r(?:\s[^>]*)?>[\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(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/g, runXml => {
const index = runCursor++;
const chunk = chunks[index] || '';
const withoutText = runXml.replace(/<w:t(?:\s[^>]*)?>[\s\S]*?<\/w:t>/g, '');
if (!chunk) return withoutText;
const textNode = `<w:t xml:space="preserve">${escapeXml(chunk)}</w:t>`;
const runEnd = withoutText.lastIndexOf('</w:r>');
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(/<w:tc(?:\s[^>]*)?>[\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(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g) || [];
if (!paragraphs.length) return cellXml;
const lines = String(cellText).split(/\r?\n/);
let paragraphCursor = 0;
return cellXml.replace(/<w:p(?:\s[^>]*)?>[\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(/<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;
});
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 '';
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')
: '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
let contentTypes = zip.file(CONTENT_TYPES_PATH)
? await zip.file(CONTENT_TYPES_PATH).async('string')
: '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>';
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(/<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 hasVisibleText = /<w:t(?:\s[^>]*)?>[^<]+<\/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,
};

137
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() || `<p>${escapeHtml(replacement)}</p>`;
};
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)}</${safeTag}>`;
return {
taskId,
fileId,
blockId,
htmlContent: `<${safeTag}>${escapeHtml(finalPlainText)}</${safeTag}>`,
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,

37
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) => {

39
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;
for (const candidate of candidates) {
const renderId = `${id}-${candidateIndex}`;
candidateIndex += 1;
try {
// 尝试渲染
const renderResult = await mermaid.render(id, fixedSource);
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 (mounted) {
setSvg(svgContent);
}
} catch (e) {
if (mounted) {
setSvg("");
if (hasErrorSvg) continue;
if (mounted) setSvg(svgContent);
return;
} catch {
// 当前候选源码失败时继续尝试规范化后的源码。
}
}
if (mounted) setSvg("");
};
render();

3
web/client/src/sections/aiTenderDocument/container/Index.jsx

@ -1159,7 +1159,8 @@ function AiTenderDocumentLayout(props) {
return (
<div
key={menuKey}
style={{ display: visible ? "block" : "none", height: "100%" }}
className={styles.activeMenuPanel}
style={{ display: visible ? "flex" : "none", height: "100%" }}
>
{renderMenuContent(menuKey)}
</div>

155
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<div class="mermaid mermaid-export"><pre data-mermaid-source="${safeSource}" style="display:none;">${safeSource}</pre><img src="${imageData}" alt="Mermaid diagram" /></div>`;
};
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),
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" });
};
})
.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]);
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() {
<Space size={4}><Button size="small" danger icon={<DeleteOutlined />} onClick={deleteParagraph}>删除段落</Button></Space>
</div>
<div className={styles.editFormatBar}>
<Button size="small" icon={<BoldOutlined />} onClick={() => tiptapRef.current?.chain().toggleBold().run()} />
<Button size="small" icon={<ItalicOutlined />} onClick={() => tiptapRef.current?.chain().toggleItalic().run()} />
<Button size="small" icon={<UnderlineOutlined />} onClick={() => tiptapRef.current?.chain().toggleUnderline().run()} />
<Button size="small" icon={<BoldOutlined />} onMouseDown={(event) => event.preventDefault()} onClick={() => tiptapRef.current?.chain().focus().toggleBold().run()} />
<Button size="small" icon={<ItalicOutlined />} onMouseDown={(event) => event.preventDefault()} onClick={() => tiptapRef.current?.chain().focus().toggleItalic().run()} />
<Button size="small" icon={<UnderlineOutlined />} onMouseDown={(event) => event.preventDefault()} onClick={() => tiptapRef.current?.chain().focus().toggleUnderline().run()} />
<Button size="small" type="primary" ghost icon={<StarFilled />} onClick={applyAiRewrite}>AI降重</Button>
</div>
<TipTapBlockEditor initialContent={editContent} onChange={setEditContent} editorRef={tiptapRef} />
@ -670,7 +689,7 @@ export default function AiPlagiarismCheck() {
return (
<div key={idx} id={blockId} className={styles.matchParaWrap}>
<p className={styles.docP + " " + clr + activeClass} onClick={() => jumpToCounterpart(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} />
<Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={() => openEditor(docIdx, idx)} />
{!mermaidOnly ? <Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={(event) => { event.stopPropagation(); openEditor(docIdx, idx); }} /> : null}
</div>
);
}
@ -699,13 +718,13 @@ export default function AiPlagiarismCheck() {
return (
<div key={idx} id={blockId} className={styles.matchParaWrap}>
<p className={styles.docP} onClick={() => jumpToCounterpart(docIdx, idx)}>{parts}</p>
<Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={() => openEditor(docIdx, idx)} />
{!mermaidOnly ? <Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={(event) => { event.stopPropagation(); openEditor(docIdx, idx); }} /> : null}
</div>
);
}
}
return (<div key={idx} id={blockId}><p className={styles.docP} onClick={() => openEditor(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} /></div>);
return (<div key={idx} id={blockId}><p className={styles.docP} onClick={mermaidOnly ? undefined : () => openEditor(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} /></div>);
};
const renderResult = () => (
@ -756,21 +775,19 @@ export default function AiPlagiarismCheck() {
const blocks = fileBlocksCache[file?.id] || [];
return (
<div key={docIdx} className={styles.panelGroup}>
<div className={styles.pane} onScroll={(event) => {
syncCounterpartScroll(docIdx, event.currentTarget);
redrawAll();
}}>
<div className={styles.pane}>
<div className={styles.paneHeader}>
<Typography.Text strong>{(nameMapRef.current[file?.originalName] || fixGarbledName(file?.originalName) || "文档" + (docIdx + 1))}</Typography.Text>
<Space size={8}>
<Button size="small" icon={<ExportOutlined />} onClick={() => handleExport(file?.id)}>导出</Button>
<Button size="small" danger icon={<CloseOutlined />} onClick={() => hidePanel(docIdx)}>隐藏</Button>
</Space>
</div>
<div className={styles.paneContent} onScroll={redrawAll}>
<div className={styles.wordDoc}>
{blocks.length > 0 ? blocks.map((b, i) => renderParagraph(b, i, docIdx))
: (<div className={styles.emptyContent}><FileTextOutlined className={styles.emptyIcon} /><Typography.Text type="secondary">加载中...</Typography.Text></div>)}
</div>
<div className={styles.paneFooter}>
<Typography.Text type="secondary">共 {blocks.length} 段 | {formatFileSize(file?.fileSize || 0)} | {file?.parseStatus || ""}</Typography.Text>
<Button icon={<ExportOutlined />} size="small" onClick={() => handleExport(file?.id)}>导出</Button>
</div>
</div>
{!isLast && (() => {
@ -780,7 +797,7 @@ export default function AiPlagiarismCheck() {
<div className={styles.gutter} id={"gutter-" + docIdx + "-" + rightIdx} ref={(el) => { if (el) requestAnimationFrame(() => redrawAll()); }}>
<div className={styles.gutterLine} />
<svg id={"gutter-svg-" + docIdx + "-" + rightIdx} className={styles.gutterSvg} />
{gm.map((m) => (<div key={m.id} id={"mid-" + docIdx + "-" + rightIdx + "-" + m.id} className={styles.midNode + " " + (m.matchType === "red" ? styles.midNodeRed : styles.midNodeYellow)} onClick={() => setCurrentFocusId((prev) => prev === m.id ? null : m.id)}><EditOutlined /></div>))}
{gm.map((m) => (<div key={m.id} id={"mid-" + docIdx + "-" + rightIdx + "-" + m.id} className={styles.midNode + " " + (m.matchType === "red" ? styles.midNodeRed : styles.midNodeYellow)} onClick={() => jumpToCounterpart(m.leftIdx, m.leftPara, m.id)}><EditOutlined /></div>))}
</div>
);
})()}

59
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;

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

3
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 = [];

97
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 ? "<br/>" : "\n";
index += 1;
continue;
}
if (current === "\n") {
result += inLabel ? "<br/>" : "\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(/<br\s*\/?>/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
);
}

4
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"}

16
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(

4
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"}

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

557
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,43 +480,9 @@ 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 || ""
@ -893,35 +490,18 @@ const SuperAgent = () => {
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 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 ||
@ -1086,6 +647,22 @@ const SuperAgent = () => {
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);
@ -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,
const taskProgressMessages = isSuspendedBodyTaskWithoutProgress
? []
: [
...(shouldHideStaleTaskProgressMessage ? [] : localTaskProgressMessages),
derivedBodyProgressMessage || currentTaskProgressMessage,
].filter(Boolean);
].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(() => {

501
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,21 +244,21 @@ 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;
if (
this.stateRequest &&
this.stateRequest.threadId === requestedThreadId
) {
return this.stateRequest.promise;
}
// ProtocolSseTransportAdapter 的 getState() 会请求当前 thread 的
// /state;其 fetchImpl 已绑定 fetchUseStreamTransport,因此会复用
// 8005 代理、Agent JWT 和 401 换票逻辑。
const state = await super.getState();
// 旧 state 快照不能在切换 thread 后覆盖新会话。
const requestVersion = ++this.stateRequestVersion;
const statePromise = super.getState()
.then((state) => {
// getState 可能在切换 thread 后才返回;旧请求不能覆盖新 thread 的快照。
if (
requestVersion === this.stateRequestVersion &&
String(this.threadId || "").trim() === requestedThreadId
@ -275,6 +267,17 @@ class SingleSubscriberSseTransportAdapter extends ProtocolSseTransportAdapter {
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);
});
//[读取标准协议事件状态][event 无法判断时由 state.task.status 兜底]
const getEventTaskStatus = (event, payload = {}) => {
if (!["lifecycle", "values"].includes(event?.method)) return "";
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;
}
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();
@ -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,30 +1019,11 @@ 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();
@ -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
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 ||
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:

48
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, /<img\b[^>]*>/gi);
const markdownImageCount = countMatches(text, /!\[[^\]]*]\([^)]+\)/g);
const htmlMermaidCount = countMatches(
text,
/<div\b[^>]*class=["'][^"']*\bmermaid\b[^"']*["'][^>]*>/gi
);
const markdownMermaidCount = countMatches(text, /```mermaid\b[\s\S]*?```/gi);
const tableCount =
countMatches(text, /<table\b[^>]*>/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: "",

32
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" },
@ -129,6 +155,8 @@ test("adapter source exchanges login token for agent jwt and omits identity head
assert.doesNotMatch(streamHook, /X-PEP-Token/);
assert.match(resources, /completeAttachmentUpload/);
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/);

Loading…
Cancel
Save