contentGenerationTask.cjs 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421
  1. const zlib = require('node:zlib');
  2. const IMAGE_STYLES = new Set(['engineering_diagram', 'realistic_photo']);
  3. const MERMAID_REPAIR_ATTEMPTS = 3;
  4. const MERMAID_RENDER_TIMEOUT_MS = 15000;
  5. const AI_IMAGE_CONCURRENCY = 2;
  6. const MERMAID_IMAGE_CONCURRENCY = 5;
  7. const TABLE_REQUIREMENT_LABELS = {
  8. none: '不要',
  9. light: '少量',
  10. moderate: '适中',
  11. heavy: '大量',
  12. };
  13. function singleLine(value) {
  14. return String(value || '').replace(/\s+/g, ' ').trim();
  15. }
  16. function normalizeGeneratedMarkdown(content) {
  17. return String(content || '')
  18. .split(/\r?\n/)
  19. .map((line) => {
  20. const normalizedLine = line.replace(/<br\s*\/?\s*>/gi, '<br />');
  21. if (normalizedLine.trim().startsWith('|')) {
  22. return normalizedLine;
  23. }
  24. return normalizedLine.replace(/\s*<br \/>\s*/g, ' \n');
  25. })
  26. .join('\n');
  27. }
  28. function normalizeMermaidCode(value) {
  29. return String(value || '')
  30. .replace(/^```mermaid\s*/i, '')
  31. .replace(/```$/i, '')
  32. .trim();
  33. }
  34. function encodeMermaidForInk(code) {
  35. const state = JSON.stringify({
  36. code: String(code || ''),
  37. mermaid: { theme: 'default' },
  38. });
  39. return `pako:${zlib.deflateSync(Buffer.from(state, 'utf-8')).toString('base64url')}`;
  40. }
  41. function mermaidInkUrl(code) {
  42. return `https://mermaid.ink/img/${encodeMermaidForInk(code)}?type=png&bgColor=!white`;
  43. }
  44. function compactError(value, maxLength = 220) {
  45. const text = String(value || '').replace(/\s+/g, ' ').trim();
  46. return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
  47. }
  48. function assertMermaidPreviewCompatible(code) {
  49. const normalized = normalizeMermaidCode(code);
  50. if (!normalized) {
  51. throw new Error('Mermaid 代码为空');
  52. }
  53. if (/[;;]/.test(normalized)) {
  54. throw new Error('Mermaid 代码包含分号,前端渲染兼容性较差,请改为每行一个语句且不使用分号');
  55. }
  56. if (/\s&\s/.test(normalized) && /-->|---|==>/.test(normalized)) {
  57. throw new Error('Mermaid 代码包含多节点 & 连接简写,请展开为多条独立连线');
  58. }
  59. if (/\[[^\]\n"']*[\u3400-\u9fff][^\]\n"']*\]/u.test(normalized)) {
  60. throw new Error('Mermaid 中文节点标签需要使用双引号,例如 A["项目启动"]');
  61. }
  62. if (/^\s*[\u3400-\u9fff][\w\u3400-\u9fff-]*\s*(?:-->|---|==>)/mu.test(normalized)) {
  63. throw new Error('Mermaid 节点 ID 需要使用 ASCII 字母数字,不要直接使用中文作为节点 ID');
  64. }
  65. }
  66. async function readResponseSnippet(response) {
  67. try {
  68. const text = await response.text();
  69. return compactError(text, 240);
  70. } catch (_error) {
  71. return '';
  72. }
  73. }
  74. async function validateMermaidRender(code) {
  75. const normalized = normalizeMermaidCode(code);
  76. assertMermaidPreviewCompatible(normalized);
  77. if (typeof fetch !== 'function') {
  78. throw new Error('当前运行环境不支持 Mermaid 渲染校验');
  79. }
  80. const controller = new AbortController();
  81. const timeout = setTimeout(() => controller.abort(), MERMAID_RENDER_TIMEOUT_MS);
  82. try {
  83. const response = await fetch(mermaidInkUrl(normalized), { signal: controller.signal });
  84. const contentType = response.headers?.get?.('content-type') || '';
  85. if (!response.ok || !/image\//i.test(contentType)) {
  86. const detail = await readResponseSnippet(response);
  87. throw new Error(`Mermaid 渲染失败:HTTP ${response.status || 'unknown'}${detail ? `,${detail}` : ''}`);
  88. }
  89. } catch (error) {
  90. if (error?.name === 'AbortError') {
  91. throw new Error('Mermaid 渲染校验超时');
  92. }
  93. throw error;
  94. } finally {
  95. clearTimeout(timeout);
  96. }
  97. }
  98. function normalizePriority(value) {
  99. const priority = Math.round(Number(value) || 0);
  100. return Math.max(1, Math.min(priority || 3, 5));
  101. }
  102. function normalizeTableRequirement(value) {
  103. const text = String(value || '').trim();
  104. if (['none', 'light', 'moderate', 'heavy'].includes(text)) {
  105. return text;
  106. }
  107. if (text === '不要') return 'none';
  108. if (text === '少量') return 'light';
  109. if (text === '适中') return 'moderate';
  110. if (text === '大量') return 'heavy';
  111. return 'heavy';
  112. }
  113. function maxTablesForRequirement(requirement, leafCount) {
  114. if (requirement === 'none') return 0;
  115. if (requirement === 'light') return Math.floor(Math.max(0, leafCount) * 0.2);
  116. if (requirement === 'moderate') return Math.floor(Math.max(0, leafCount) * 0.4);
  117. return null;
  118. }
  119. function clearContentPlanTable(contentPlan) {
  120. return {
  121. ...contentPlan,
  122. table: {
  123. needed: false,
  124. purpose: '',
  125. },
  126. };
  127. }
  128. function normalizeKnowledgeItemIds(value, allowedKnowledgeItemIds) {
  129. const source = Array.isArray(value) ? value : [];
  130. const ids = source.map((id) => String(id || '').trim()).filter(Boolean);
  131. const filtered = allowedKnowledgeItemIds instanceof Set
  132. ? ids.filter((id) => allowedKnowledgeItemIds.has(id))
  133. : ids;
  134. return [...new Set(filtered)];
  135. }
  136. function normalizeContentPlan(value, allowedKnowledgeItemIds) {
  137. const source = value?.plan && typeof value.plan === 'object' ? value.plan : value || {};
  138. const knowledgeSource = source.knowledge;
  139. const knowledge = knowledgeSource && typeof knowledgeSource === 'object' && !Array.isArray(knowledgeSource) ? knowledgeSource : {};
  140. const rawKnowledgeItemIds = Array.isArray(knowledgeSource)
  141. ? knowledgeSource
  142. : knowledge.item_ids ?? knowledge.itemIds ?? knowledge.knowledge_item_ids ?? source.knowledge_item_ids ?? source.knowledgeItemIds;
  143. const table = source.table && typeof source.table === 'object' ? source.table : {};
  144. const image = source.image && typeof source.image === 'object' ? source.image : {};
  145. const mermaid = source.mermaid && typeof source.mermaid === 'object' ? source.mermaid : {};
  146. const tableNeeded = Boolean(table.needed);
  147. const mermaidTitle = singleLine(mermaid.title);
  148. const mermaidCode = normalizeMermaidCode(mermaid.code);
  149. const mermaidNeeded = Boolean(mermaid.needed) && Boolean(mermaidTitle && mermaidCode);
  150. const imageStyle = IMAGE_STYLES.has(image.style) ? image.style : '';
  151. const imageTitle = singleLine(image.title);
  152. const imagePrompt = String(image.prompt || '').trim();
  153. const imageNeeded = Boolean(image.needed) && Boolean(imageStyle && imageTitle && imagePrompt);
  154. return {
  155. knowledge: {
  156. item_ids: normalizeKnowledgeItemIds(rawKnowledgeItemIds, allowedKnowledgeItemIds),
  157. },
  158. table: {
  159. needed: tableNeeded,
  160. purpose: tableNeeded ? singleLine(table.purpose) : '',
  161. },
  162. mermaid: {
  163. needed: mermaidNeeded,
  164. title: mermaidNeeded ? mermaidTitle : '',
  165. code: mermaidNeeded ? mermaidCode : '',
  166. priority: mermaidNeeded ? normalizePriority(mermaid.priority) : 0,
  167. reason: mermaidNeeded ? singleLine(mermaid.reason) : '',
  168. },
  169. image: {
  170. needed: imageNeeded,
  171. style: imageNeeded ? imageStyle : '',
  172. title: imageNeeded ? imageTitle : '',
  173. prompt: imageNeeded ? imagePrompt : '',
  174. priority: imageNeeded ? normalizePriority(image.priority) : 0,
  175. reason: imageNeeded ? singleLine(image.reason) : '',
  176. },
  177. };
  178. }
  179. function normalizeIllustrationType(value) {
  180. return ['ai', 'mermaid', 'none'].includes(value) ? value : 'none';
  181. }
  182. function createStoredContentPlan(plan, illustrationType) {
  183. return {
  184. plan: normalizeContentPlan(plan),
  185. illustration_type: normalizeIllustrationType(illustrationType),
  186. updated_at: now(),
  187. };
  188. }
  189. function normalizeStoredContentPlan(value) {
  190. if (!value || typeof value !== 'object') {
  191. return null;
  192. }
  193. const plan = normalizeContentPlan(value.plan || value.contentPlan || value);
  194. return {
  195. plan,
  196. illustration_type: normalizeIllustrationType(value.illustration_type || value.illustrationType),
  197. updated_at: value.updated_at || value.updatedAt || now(),
  198. };
  199. }
  200. function pruneContentGenerationPlans(plans, leaves) {
  201. const leafIds = new Set(leaves.map(({ item }) => item.id));
  202. const next = {};
  203. for (const [itemId, value] of Object.entries(plans || {})) {
  204. if (!leafIds.has(itemId)) {
  205. continue;
  206. }
  207. const storedPlan = normalizeStoredContentPlan(value);
  208. if (storedPlan) {
  209. next[itemId] = storedPlan;
  210. }
  211. }
  212. return next;
  213. }
  214. function validateContentPlan(plan) {
  215. if (!plan || typeof plan !== 'object') {
  216. throw new Error('正文编排决策必须是对象');
  217. }
  218. if (!plan.knowledge || !Array.isArray(plan.knowledge.item_ids)) {
  219. throw new Error('正文编排决策缺少 knowledge.item_ids');
  220. }
  221. if (!plan.table || typeof plan.table.needed !== 'boolean') {
  222. throw new Error('正文编排决策缺少 table.needed');
  223. }
  224. if (!plan.image || typeof plan.image.needed !== 'boolean') {
  225. throw new Error('正文编排决策缺少 image.needed');
  226. }
  227. if (!plan.mermaid || typeof plan.mermaid.needed !== 'boolean') {
  228. throw new Error('正文编排决策缺少 mermaid.needed');
  229. }
  230. if (plan.image.needed && !IMAGE_STYLES.has(plan.image.style)) {
  231. throw new Error('正文配图风格无效');
  232. }
  233. }
  234. function normalizeMermaidRepairResult(value) {
  235. const source = value?.result && typeof value.result === 'object' ? value.result : value || {};
  236. return {
  237. code: normalizeMermaidCode(source.code || source.fixed_code || source.mermaid_code || source.mermaid?.code || ''),
  238. };
  239. }
  240. function validateMermaidRepairResult(result) {
  241. if (!result?.code) {
  242. throw new Error('Mermaid 修复结果缺少 code');
  243. }
  244. if (/```/.test(result.code)) {
  245. throw new Error('Mermaid 修复结果不能包含 Markdown 代码围栏');
  246. }
  247. }
  248. function formatContentPlanForPrompt(plan) {
  249. const lines = [
  250. `表格:${plan.table.needed ? `需要,目的:${plan.table.purpose || '提升正文表达清晰度'}` : '不需要,本小节不要输出 Markdown 表格'}`,
  251. `AI 生图:${plan.image.needed ? `需要,风格:${plan.image.style},标题:${plan.image.title}` : '不需要'}`,
  252. ];
  253. return lines.join('\n');
  254. }
  255. function buildMermaidRepairMessages({ chapter, parentChapters, siblingChapters, projectOverview, regenerateRequirement, mermaidPlan, invalidCode, errorMessage, attempt }) {
  256. const chapterId = chapter.id || 'unknown';
  257. const chapterTitle = chapter.title || '未命名章节';
  258. const messages = [
  259. {
  260. role: 'system',
  261. content: `你是 Mermaid 图代码修复助手。请根据渲染错误修复现有 Mermaid 代码。
  262. 要求:
  263. 1. 只返回 JSON,不要输出解释、总结或 Markdown。
  264. 2. 目标是让 Mermaid 在浏览器前端稳定渲染,优先做最小必要修改。
  265. 3. 优先使用 flowchart TD;节点 ID 只使用 ASCII 字母、数字和下划线。
  266. 4. 中文节点标签必须写成 A["中文标签"],不要写成 A[中文标签]。
  267. 5. 不使用 & 多节点连接简写,必须展开成多条独立连线。
  268. 6. 不使用分号;每行只写一个 Mermaid 语句。
  269. 7. 不要输出 Markdown 代码围栏。
  270. 8. 如果原图结构过于复杂,请简化为可渲染的核心流程图。`,
  271. },
  272. ];
  273. if (String(projectOverview || '').trim()) {
  274. messages.push({ role: 'user', content: `项目概述信息:\n${projectOverview}` });
  275. }
  276. if (parentChapters?.length) {
  277. messages.push({
  278. role: 'user',
  279. content: ['上级章节信息:', ...parentChapters.map((parent) => `- ${parent.id || 'unknown'} ${parent.title || '未命名章节'}\n ${parent.description || ''}`)].join('\n'),
  280. });
  281. }
  282. if (siblingChapters?.length) {
  283. const siblingLines = ['同级章节信息:'];
  284. for (const sibling of siblingChapters) {
  285. if (sibling.id !== chapterId) {
  286. siblingLines.push(`- ${sibling.id || 'unknown'} ${sibling.title || '未命名章节'}\n ${sibling.description || ''}`);
  287. }
  288. }
  289. if (siblingLines.length > 1) {
  290. messages.push({ role: 'user', content: siblingLines.join('\n') });
  291. }
  292. }
  293. if (String(regenerateRequirement || '').trim()) {
  294. messages.push({ role: 'user', content: `用户对本次重新生成的额外要求:\n${regenerateRequirement}` });
  295. }
  296. messages.push({
  297. role: 'user',
  298. content: `当前章节:${chapterId} ${chapterTitle}
  299. 章节描述:${chapter.description || ''}
  300. Mermaid 图标题:${mermaidPlan.title || '流程图'}
  301. 修复轮次:${attempt}/${MERMAID_REPAIR_ATTEMPTS}
  302. 渲染错误:${errorMessage || '未知错误'}
  303. 待修复 Mermaid 代码:
  304. \`\`\`mermaid
  305. ${normalizeMermaidCode(invalidCode)}
  306. \`\`\`
  307. 请返回 JSON:
  308. {
  309. "code": "修复后的 Mermaid 代码,不包含 Markdown 代码围栏"
  310. }`,
  311. });
  312. return messages;
  313. }
  314. function renderKnowledgeItemsForPrompt(items) {
  315. return JSON.stringify((items || []).map((item) => ({
  316. id: String(item.id || '').trim(),
  317. title: String(item.title || '').trim(),
  318. resume: String(item.resume || '').trim(),
  319. })).filter((item) => item.id && item.title && item.resume), null, 2);
  320. }
  321. function buildChapterContentPlanMessages({ chapter, parentChapters, siblingChapters, projectOverview, regenerateRequirement, tableRequirement, maxTables, tableTotalSections, imageGenerationAvailable, mermaidGenerationAvailable, maxAiImages, totalSections, knowledgeItems }) {
  322. const chapterId = chapter.id || 'unknown';
  323. const chapterTitle = chapter.title || '未命名章节';
  324. const chapterDescription = chapter.description || '';
  325. const tableRequirementLabel = TABLE_REQUIREMENT_LABELS[tableRequirement] || TABLE_REQUIREMENT_LABELS.heavy;
  326. const tablePlanningAllowed = tableRequirement !== 'none';
  327. const tableLimitInstruction = tableRequirement === 'heavy'
  328. ? '表格需求为“大量”,保持现有编排逻辑;仍然只有明显适合表格的小节才将 table.needed 设为 true。'
  329. : tableRequirement === 'none'
  330. ? '表格需求为“不要”,table.needed 必须为 false,table.purpose 留空。'
  331. : `表格需求为“${tableRequirementLabel}”,table.needed 表示进入表格候选池,不代表最终一定生成;全文表格上限为 ${maxTables || 0} 个,共 ${tableTotalSections || totalSections || 0} 个叶子小节,系统后续会全局择优。`;
  332. const messages = [
  333. {
  334. role: 'system',
  335. content: `你是投标技术方案正文编排助手。请根据章节上下文判断本小节最适合的表达方式。
  336. 要求:
  337. 1. 只返回 JSON,不要输出解释、总结或 Markdown。
  338. 2. ${tablePlanningAllowed ? '由你自行判断是否适合使用表格或配图,判断要克制、合情合理,不要为了形式而硬插。' : '本次不编排表格,table.needed 必须为 false;仍可判断是否适合配图。'}
  339. 3. ${tableLimitInstruction}
  340. 4. ${tablePlanningAllowed ? '表格仅在能明显提升表达清晰度时使用,例如归纳职责、步骤、参数、风险、措施、成果等。' : '不要为了满足 JSON 格式而编造表格目的。'}
  341. 5. ${mermaidGenerationAvailable ? '可以自行判断是否需要 Mermaid 图;Mermaid 只适合简单、抽象、文本节点型关系图,例如少量节点的流程、层级、时间线或职责关系,不用于复杂工程场景或实物示意。' : '当前未启用 Mermaid 图,mermaid.needed 必须为 false。'}
  342. 6. ${imageGenerationAvailable ? '可以自行判断是否需要 AI 生图;AI 生图适合设备、现场、机柜、电池、系统架构、部署拓扑、施工/运维场景、工程空间关系、实物示意等更具象的图。' : '当前未启用或不可用 AI 生图,image.needed 必须为 false。'}
  343. 7. Mermaid 图和 AI 生图都只是候选判断,可以同时为 true;系统会在配图阶段保证同一个章节最终只执行一种配图。
  344. 8. ${imageGenerationAvailable ? `image.needed 表示进入 AI 生图候选池,不代表最终一定生成;本次 AI 生图上限为 ${maxAiImages || 0} 张,共 ${totalSections || 0} 个小节,系统后续会全局择优。` : '由于 AI 生图不可用,image 字段只需返回不需要。'}
  345. 9. ${imageGenerationAvailable ? '不要求用满 AI 生图上限;但遇到具象工程对象或现场场景时,不要过度保守,可以适度提名候选。没有具象对象、空间关系或实物场景时仍不要硬插。' : '不要为了满足格式而编造 AI 生图需求。'}
  346. 10. priority 含义:3 表示有价值候选,4 表示推荐,5 表示强推荐;只有达到 3 才将 image.needed 设为 true。
  347. 11. engineering_diagram 表示工程图示风,适合系统架构、部署拓扑、设备连接、机柜布置、电池更换方案、施工组织或运维场景示意等具象工程图。
  348. 12. realistic_photo 表示专业实景示意风,适合设备、场地、机房、施工现场、检测工具、运维操作等真实场景表现。
  349. 13. knowledge.item_ids 只能从参考知识库轻量条目的 id 中选择;可以多选,可以为空数组;不要编造 id,不要输出 reason。`,
  350. },
  351. ];
  352. messages.push({
  353. role: 'user',
  354. content: `参考知识库轻量条目(只包含 id、标题和简介,不包含正文;如无合适条目,knowledge.item_ids 返回空数组):
  355. ${renderKnowledgeItemsForPrompt(knowledgeItems)}`,
  356. });
  357. if (String(projectOverview || '').trim()) {
  358. messages.push({ role: 'user', content: `项目概述信息:\n${projectOverview}` });
  359. }
  360. if (parentChapters?.length) {
  361. messages.push({
  362. role: 'user',
  363. content: ['上级章节信息:', ...parentChapters.map((parent) => `- ${parent.id || 'unknown'} ${parent.title || '未命名章节'}\n ${parent.description || ''}`)].join('\n'),
  364. });
  365. }
  366. if (siblingChapters?.length) {
  367. const siblingLines = ['同级章节信息:'];
  368. for (const sibling of siblingChapters) {
  369. if (sibling.id !== chapterId) {
  370. siblingLines.push(`- ${sibling.id || 'unknown'} ${sibling.title || '未命名章节'}\n ${sibling.description || ''}`);
  371. }
  372. }
  373. if (siblingLines.length > 1) {
  374. messages.push({ role: 'user', content: siblingLines.join('\n') });
  375. }
  376. }
  377. if (String(regenerateRequirement || '').trim()) {
  378. messages.push({ role: 'user', content: `用户对本次重新生成的额外要求:\n${regenerateRequirement}` });
  379. }
  380. messages.push({
  381. role: 'user',
  382. content: `请为以下章节返回正文编排 JSON:
  383. 章节ID: ${chapterId}
  384. 章节标题: ${chapterTitle}
  385. 章节描述: ${chapterDescription}
  386. JSON 格式:
  387. {
  388. "knowledge": {
  389. "item_ids": ["从参考知识库轻量条目中选择的 id;没有合适条目时返回空数组"]
  390. },
  391. "table": {
  392. "needed": true,
  393. "purpose": "说明表格在本小节中要表达什么;不需要表格时留空"
  394. },
  395. "mermaid": {
  396. "needed": false,
  397. "title": "Mermaid 图标题;不需要时留空",
  398. "code": "合法 Mermaid 代码,不包含 Markdown 代码围栏;不需要时留空",
  399. "priority": 3,
  400. "reason": "为什么适合或不适合 Mermaid 图"
  401. },
  402. "image": {
  403. "needed": false,
  404. "style": "engineering_diagram 或 realistic_photo;不需要配图时留空",
  405. "title": "图片标题;不需要配图时留空",
  406. "prompt": "用于生图模型的中文提示词;不需要配图时留空",
  407. "priority": 3,
  408. "reason": "为什么适合或不适合 AI 生图"
  409. }
  410. }`,
  411. });
  412. return messages;
  413. }
  414. function formatKnowledgeContentsForPrompt(contents) {
  415. return (contents || [])
  416. .map((content) => `<knowledge_content>\n${String(content || '').trim()}\n</knowledge_content>`)
  417. .join('\n\n');
  418. }
  419. function buildChapterContentMessages({ chapter, parentChapters, siblingChapters, projectOverview, regenerateRequirement, contentPlan, knowledgeContents }) {
  420. const chapterId = chapter.id || 'unknown';
  421. const chapterTitle = chapter.title || '未命名章节';
  422. const chapterDescription = chapter.description || '';
  423. const messages = [
  424. {
  425. role: 'system',
  426. content: `你是一个专业的标书编写专家,负责为投标文件的技术标部分生成具体内容。
  427. 要求:
  428. 1. 内容要专业、准确,与章节标题和描述保持一致。
  429. 2. 这是技术方案,不是宣传报告,注意朴实无华,不要假大空。
  430. 3. 语言要正式、规范,符合标书写作要求,但不要使用奇怪的连接词,不要让人觉得内容像是 AI 生成的。
  431. 4. 内容要详细具体,避免空泛的描述。
  432. 5. 注意避免与同级章节内容重复,保持内容的独特性和互补性。
  433. 6. 可以使用 Markdown 段落、列表和表格;表格必须服务于内容表达,不要为了形式硬插。
  434. 7. 正文只生成文字、列表、表格等内容,配图由系统另行处理。
  435. 8. 严禁输出 Mermaid、PlantUML、Graphviz、flowchart、graph、sequenceDiagram 等图表代码块、mermaid.ink 链接或图片 Markdown;配图由系统另行处理。
  436. 9. 表格单元格内如有多项内容,优先使用编号、顿号、分号或短句,不要使用 HTML <br> 标签。
  437. 10. 直接返回章节内容,不生成标题,不要任何额外说明。`,
  438. },
  439. ];
  440. if (String(projectOverview || '').trim()) {
  441. messages.push({ role: 'user', content: `项目概述信息:\n${projectOverview}` });
  442. }
  443. if (knowledgeContents?.length) {
  444. messages.push({
  445. role: 'user',
  446. content: '参考正文素材使用规则:以下内容只作为可吸收的技术素材。请改写为当前项目语境下的投标技术方案正文,不要照抄,不要提到“知识库”“历史文档”“参考资料”或素材来源。',
  447. });
  448. messages.push({
  449. role: 'user',
  450. content: `参考正文素材:\n${formatKnowledgeContentsForPrompt(knowledgeContents)}`,
  451. });
  452. }
  453. if (parentChapters?.length) {
  454. const parentLines = ['上级章节信息:'];
  455. for (const parent of parentChapters) {
  456. parentLines.push(`- ${parent.id || 'unknown'} ${parent.title || '未命名章节'}\n ${parent.description || ''}`);
  457. }
  458. messages.push({ role: 'user', content: parentLines.join('\n') });
  459. }
  460. if (siblingChapters?.length) {
  461. const siblingLines = ['同级章节信息(请避免内容重复):'];
  462. for (const sibling of siblingChapters) {
  463. if (sibling.id === chapterId) {
  464. continue;
  465. }
  466. siblingLines.push(`- ${sibling.id || 'unknown'} ${sibling.title || '未命名章节'}\n ${sibling.description || ''}`);
  467. }
  468. if (siblingLines.length > 1) {
  469. messages.push({ role: 'user', content: siblingLines.join('\n') });
  470. }
  471. }
  472. if (String(regenerateRequirement || '').trim()) {
  473. messages.push({
  474. role: 'user',
  475. content: `用户对本次重新生成的额外要求:\n${regenerateRequirement}`,
  476. });
  477. }
  478. if (contentPlan) {
  479. messages.push({
  480. role: 'user',
  481. content: `正文编排决策:\n${formatContentPlanForPrompt(contentPlan)}`,
  482. });
  483. }
  484. messages.push({
  485. role: 'user',
  486. content: `请为以下标书章节生成具体内容:
  487. 当前章节信息:
  488. 章节ID: ${chapterId}
  489. 章节标题: ${chapterTitle}
  490. 章节描述: ${chapterDescription}
  491. 请根据项目概述信息和上述章节层级关系,生成详细的专业内容,确保与上级章节的内容逻辑相承,同时避免与同级章节内容重复,突出本章节的独特性和技术方案优势。
  492. 直接返回编写的正文内容,不要输出标题、解释、总结等任何其他内容`,
  493. });
  494. return messages;
  495. }
  496. function normalizeChildren(item) {
  497. return Array.isArray(item.children) ? item.children : [];
  498. }
  499. function collectLeafContexts(items, parents = []) {
  500. const results = [];
  501. for (const item of items || []) {
  502. const children = normalizeChildren(item);
  503. if (!children.length) {
  504. results.push({ item, parentChapters: parents, siblingChapters: items || [] });
  505. continue;
  506. }
  507. results.push(...collectLeafContexts(children, [...parents, item]));
  508. }
  509. return results;
  510. }
  511. function normalizeReferenceDocumentIds(payload, storedPlan) {
  512. const raw = payload?.reference_knowledge_document_ids
  513. ?? payload?.referenceKnowledgeDocumentIds
  514. ?? storedPlan?.referenceKnowledgeDocumentIds
  515. ?? [];
  516. return Array.isArray(raw)
  517. ? [...new Set(raw.map((id) => String(id || '').trim()).filter(Boolean))]
  518. : [];
  519. }
  520. function loadContentKnowledgeItems(knowledgeBaseService, documentIds, log) {
  521. if (!documentIds.length) {
  522. log('本次正文编排未选择参考知识库。');
  523. return [];
  524. }
  525. if (!knowledgeBaseService?.getOutlineReferences) {
  526. log('未找到知识库读取服务,正文编排不使用知识库。');
  527. return [];
  528. }
  529. try {
  530. const result = knowledgeBaseService.getOutlineReferences(documentIds);
  531. const items = Array.isArray(result?.items) ? result.items.map((item) => ({
  532. id: String(item?.id || '').trim(),
  533. title: String(item?.title || '').trim(),
  534. resume: String(item?.resume || '').trim(),
  535. })).filter((item) => item.id && item.title && item.resume) : [];
  536. log(items.length ? `正文编排已读取 ${items.length} 条知识库轻量条目。` : '未读取到可用知识库轻量条目,正文编排不使用知识库。');
  537. return items;
  538. } catch (error) {
  539. log(`读取正文编排参考知识库失败,已跳过:${error.message || String(error)}`);
  540. return [];
  541. }
  542. }
  543. function loadContentKnowledgeContentMap(knowledgeBaseService, documentIds, log) {
  544. const map = new Map();
  545. if (!documentIds.length || !knowledgeBaseService?.readItems) {
  546. return map;
  547. }
  548. for (const documentId of documentIds) {
  549. try {
  550. const items = knowledgeBaseService.readItems(documentId);
  551. for (const item of Array.isArray(items) ? items : []) {
  552. const itemId = String(item?.id || '').trim();
  553. const content = String(item?.content || '').trim();
  554. if (!itemId || !content) {
  555. continue;
  556. }
  557. map.set(`${documentId}::${itemId}`, { content });
  558. }
  559. } catch (error) {
  560. log(`读取知识库正文素材失败,已跳过文档 ${documentId}:${error.message || String(error)}`);
  561. }
  562. }
  563. if (map.size) {
  564. log(`正文生成可用知识库正文素材 ${map.size} 条。`);
  565. }
  566. return map;
  567. }
  568. function resolveKnowledgeContents(itemIds, knowledgeContentMap) {
  569. const selected = new Set(normalizeKnowledgeItemIds(itemIds));
  570. if (!selected.size || !(knowledgeContentMap instanceof Map) || !knowledgeContentMap.size) {
  571. return [];
  572. }
  573. const contents = [];
  574. for (const [id, item] of knowledgeContentMap.entries()) {
  575. if (selected.has(id) && item?.content) {
  576. contents.push(item.content);
  577. }
  578. }
  579. return contents;
  580. }
  581. function updateOutlineItemContent(items, targetId, content) {
  582. return (items || []).map((item) => {
  583. if (item.id === targetId) {
  584. return { ...item, content };
  585. }
  586. const children = normalizeChildren(item);
  587. if (!children.length) {
  588. return item;
  589. }
  590. return { ...item, children: updateOutlineItemContent(children, targetId, content) };
  591. });
  592. }
  593. function clearOutlineContent(items) {
  594. return (items || []).map((item) => {
  595. const { content, children, ...rest } = item;
  596. const normalizedChildren = normalizeChildren(item);
  597. return normalizedChildren.length
  598. ? { ...rest, children: clearOutlineContent(normalizedChildren) }
  599. : rest;
  600. });
  601. }
  602. function escapeRegExp(value) {
  603. return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  604. }
  605. function unwrapMarkdownTitle(line) {
  606. let normalized = String(line || '').trim();
  607. normalized = normalized.replace(/^#{1,6}\s+/, '').trim();
  608. normalized = normalized.replace(/^\*\*(.+)\*\*$/, '$1').trim();
  609. normalized = normalized.replace(/^__(.+)__$/, '$1').trim();
  610. return normalized.replace(/[:::。\s]+$/, '').trim();
  611. }
  612. function stripRepeatedChapterTitle(content, chapter) {
  613. const title = String(chapter?.title || '').trim();
  614. if (!title) {
  615. return content;
  616. }
  617. const rawLines = String(content || '').replace(/^\uFEFF/, '').split(/\r?\n/);
  618. let firstContentLine = rawLines.findIndex((line) => line.trim());
  619. if (firstContentLine < 0) {
  620. return content;
  621. }
  622. const chapterId = String(chapter?.id || '').trim();
  623. const firstLine = unwrapMarkdownTitle(rawLines[firstContentLine]);
  624. let comparable = firstLine;
  625. if (chapterId) {
  626. comparable = comparable.replace(new RegExp(`^${escapeRegExp(chapterId)}\\s+`), '').trim();
  627. }
  628. comparable = comparable.replace(/^[一二三四五六七八九十]+[、..]\s*/, '').trim();
  629. if (comparable !== title && firstLine !== `${chapterId} ${title}`.trim()) {
  630. return content;
  631. }
  632. const nextLines = rawLines.slice(firstContentLine + 1);
  633. while (nextLines.length && !nextLines[0].trim()) {
  634. nextLines.shift();
  635. }
  636. return [...rawLines.slice(0, firstContentLine), ...nextLines].join('\n').trimStart();
  637. }
  638. function appendGeneratedImageMarkdown(content, imagePlan, generatedImage) {
  639. if (!generatedImage?.asset_url) {
  640. return content;
  641. }
  642. const title = singleLine(imagePlan.title || generatedImage.title || '技术方案配图');
  643. const caption = title.endsWith('示意图') ? title : `${title}示意图`;
  644. const normalizedContent = String(content || '').trimEnd();
  645. return `${normalizedContent}\n\n![${caption}](${generatedImage.asset_url})\n\n*图:${caption}*`;
  646. }
  647. function appendMermaidImageMarkdown(content, mermaidPlan) {
  648. if (!mermaidPlan?.code) {
  649. return content;
  650. }
  651. const title = singleLine(mermaidPlan.title || '流程图');
  652. const caption = title.endsWith('图') ? title : `${title}图`;
  653. const code = normalizeMermaidCode(mermaidPlan.code);
  654. const normalizedContent = String(content || '').trimEnd();
  655. return `${normalizedContent}\n\n\`\`\`mermaid\n${code}\n\`\`\`\n\n*图:${caption}*`;
  656. }
  657. async function prepareRenderableMermaidPlan({ aiService, context, projectOverview, regenerateRequirement, mermaidPlan }) {
  658. const { item, parentChapters, siblingChapters } = context;
  659. let currentPlan = { ...mermaidPlan, code: normalizeMermaidCode(mermaidPlan.code) };
  660. let lastError = null;
  661. try {
  662. await validateMermaidRender(currentPlan.code);
  663. return { ok: true, plan: currentPlan, attempts: 0 };
  664. } catch (error) {
  665. lastError = error;
  666. }
  667. for (let attempt = 1; attempt <= MERMAID_REPAIR_ATTEMPTS; attempt += 1) {
  668. try {
  669. const repaired = await aiService.collectJsonResponse({
  670. messages: buildMermaidRepairMessages({
  671. chapter: item,
  672. parentChapters,
  673. siblingChapters,
  674. projectOverview,
  675. regenerateRequirement,
  676. mermaidPlan: currentPlan,
  677. invalidCode: currentPlan.code,
  678. errorMessage: compactError(lastError?.message || lastError),
  679. attempt,
  680. }),
  681. temperature: 0.1,
  682. progressLabel: 'Mermaid 配图修复',
  683. failureMessage: '模型返回的 Mermaid 修复结果格式无效',
  684. normalizer: normalizeMermaidRepairResult,
  685. validator: validateMermaidRepairResult,
  686. max_retries: 1,
  687. });
  688. currentPlan = { ...currentPlan, code: repaired.code };
  689. await validateMermaidRender(currentPlan.code);
  690. return { ok: true, plan: currentPlan, attempts: attempt };
  691. } catch (error) {
  692. lastError = error;
  693. }
  694. }
  695. return { ok: false, plan: currentPlan, attempts: MERMAID_REPAIR_ATTEMPTS, error: compactError(lastError?.message || lastError || '渲染失败') };
  696. }
  697. function pickDistributedImageTargets(plannedItems, limit) {
  698. if (limit <= 0 || !plannedItems.length) {
  699. return new Set();
  700. }
  701. if (plannedItems.length <= limit) {
  702. return new Set(plannedItems.map(({ item }) => item.id));
  703. }
  704. const selected = new Map();
  705. for (let slot = 0; slot < limit; slot += 1) {
  706. const start = Math.floor((slot * plannedItems.length) / limit);
  707. const end = Math.floor(((slot + 1) * plannedItems.length) / limit);
  708. const group = plannedItems.slice(start, Math.max(start + 1, end));
  709. const best = group.reduce((current, candidate) => (
  710. candidate.plan.image.priority > current.plan.image.priority ? candidate : current
  711. ), group[0]);
  712. selected.set(best.item.id, best);
  713. }
  714. if (selected.size < limit) {
  715. const remaining = plannedItems
  716. .filter(({ item }) => !selected.has(item.id))
  717. .sort((a, b) => b.plan.image.priority - a.plan.image.priority);
  718. for (const candidate of remaining) {
  719. if (selected.size >= limit) break;
  720. selected.set(candidate.item.id, candidate);
  721. }
  722. }
  723. return new Set(selected.keys());
  724. }
  725. function pickDistributedTableTargets(plannedItems, limit) {
  726. if (limit <= 0 || !plannedItems.length) {
  727. return new Set();
  728. }
  729. if (plannedItems.length <= limit) {
  730. return new Set(plannedItems.map(({ item }) => item.id));
  731. }
  732. const selected = new Map();
  733. for (let slot = 0; slot < limit; slot += 1) {
  734. const start = Math.floor((slot * plannedItems.length) / limit);
  735. const end = Math.floor(((slot + 1) * plannedItems.length) / limit);
  736. const group = plannedItems.slice(start, Math.max(start + 1, end));
  737. const candidate = group[Math.floor(group.length / 2)] || group[0];
  738. selected.set(candidate.item.id, candidate);
  739. }
  740. return new Set(selected.keys());
  741. }
  742. function countRetainedTablePlans(plans, excludedItemIds) {
  743. let count = 0;
  744. for (const [itemId, value] of Object.entries(plans || {})) {
  745. if (excludedItemIds?.has(itemId)) {
  746. continue;
  747. }
  748. const storedPlan = normalizeStoredContentPlan(value);
  749. if (storedPlan?.plan?.table?.needed) {
  750. count += 1;
  751. }
  752. }
  753. return count;
  754. }
  755. function createImageStat() {
  756. return { planned: 0, attempted: 0, success: 0, failed: 0, skipped: 0 };
  757. }
  758. function sumImageStats(ai, mermaid) {
  759. return {
  760. planned: ai.planned + mermaid.planned,
  761. attempted: ai.attempted + mermaid.attempted,
  762. success: ai.success + mermaid.success,
  763. failed: ai.failed + mermaid.failed,
  764. skipped: ai.skipped + mermaid.skipped,
  765. };
  766. }
  767. async function runWithConcurrency(items, limit, worker) {
  768. const workerCount = Math.min(Math.max(1, limit), items.length);
  769. let nextIndex = 0;
  770. async function runWorker() {
  771. while (nextIndex < items.length) {
  772. const item = items[nextIndex];
  773. nextIndex += 1;
  774. await worker(item);
  775. }
  776. }
  777. await Promise.all(Array.from({ length: workerCount }, runWorker));
  778. }
  779. function createInitialSections(leaves, existingSections) {
  780. const next = { ...(existingSections || {}) };
  781. const leafIds = new Set(leaves.map(({ item }) => item.id));
  782. for (const key of Object.keys(next)) {
  783. if (!leafIds.has(key)) {
  784. delete next[key];
  785. }
  786. }
  787. for (const { item } of leaves) {
  788. const existing = next[item.id];
  789. const content = existing?.content || item.content || '';
  790. const existingStatus = existing?.status === 'running' ? undefined : existing?.status;
  791. next[item.id] = {
  792. id: item.id,
  793. title: item.title || '未命名章节',
  794. status: existingStatus || (content.trim() ? 'success' : 'idle'),
  795. content,
  796. error: existing?.error,
  797. updated_at: existing?.updated_at,
  798. };
  799. }
  800. return next;
  801. }
  802. function progressFor(leaves, sections) {
  803. if (!leaves.length) {
  804. return 0;
  805. }
  806. const done = leaves.filter(({ item }) => ['success', 'error'].includes(sections[item.id]?.status)).length;
  807. return Math.round((done / leaves.length) * 100);
  808. }
  809. function taskStatusFor(leaves, sections) {
  810. if (leaves.some(({ item }) => sections[item.id]?.status === 'error')) {
  811. return 'error';
  812. }
  813. return 'success';
  814. }
  815. function now() {
  816. return new Date().toISOString();
  817. }
  818. function withSection(sections, item, partial) {
  819. return {
  820. ...(sections || {}),
  821. [item.id]: {
  822. id: item.id,
  823. title: item.title || '未命名章节',
  824. status: 'idle',
  825. content: '',
  826. ...(sections || {})[item.id],
  827. ...partial,
  828. updated_at: now(),
  829. },
  830. };
  831. }
  832. async function runContentGenerationTask({ aiService, workspaceStore, knowledgeBaseService, updateTask, payload }) {
  833. const storedPlan = workspaceStore.loadTechnicalPlan() || {};
  834. let outlineData = payload.outlineData || storedPlan.outlineData;
  835. if (!outlineData?.outline?.length) {
  836. throw new Error('请先生成目录,再生成正文');
  837. }
  838. const projectOverview = payload.projectOverview || outlineData.project_overview || storedPlan.projectOverview || '';
  839. const regenerate = Boolean(payload.regenerate);
  840. const targetItemId = String(payload.targetItemId || '').trim();
  841. const fullRegenerate = regenerate && !targetItemId;
  842. if (fullRegenerate) {
  843. outlineData = { ...outlineData, outline: clearOutlineContent(outlineData.outline) };
  844. }
  845. const leaves = collectLeafContexts(outlineData.outline);
  846. if (!leaves.length) {
  847. throw new Error('当前目录没有可生成正文的小节');
  848. }
  849. const regenerateRequirement = String(payload.requirement || '').trim();
  850. const concurrency = Math.max(1, Math.min(Number(payload.concurrency) || 5, 8));
  851. const generationOptions = payload.generationOptions || payload.generation_options || {};
  852. const realTimeRender = payload.real_time_render !== false && payload.realTimeRender !== false;
  853. const tableRequirement = normalizeTableRequirement(generationOptions.tableRequirement ?? generationOptions.table_requirement);
  854. const maxTables = maxTablesForRequirement(tableRequirement, leaves.length);
  855. const referenceKnowledgeDocumentIds = normalizeReferenceDocumentIds(payload, storedPlan);
  856. const imageAvailability = aiService.getImageModelAvailability
  857. ? aiService.getImageModelAvailability()
  858. : { available: false, message: '生图模型不可用' };
  859. const aiImagesEnabled = Boolean(generationOptions.useAiImages ?? generationOptions.use_ai_images ?? imageAvailability.available) && imageAvailability.available;
  860. const mermaidImagesEnabled = Boolean(generationOptions.useMermaidImages ?? generationOptions.use_mermaid_images ?? Boolean(targetItemId));
  861. const requestedMaxImages = Number(generationOptions.maxAiImages ?? generationOptions.max_ai_images);
  862. const maxAiImages = aiImagesEnabled
  863. ? Math.max(0, Math.min(Number.isFinite(requestedMaxImages) ? Math.round(requestedMaxImages) : 6, targetItemId ? 1 : leaves.length))
  864. : 0;
  865. const imageStats = { ai: createImageStat(), mermaid: createImageStat() };
  866. const contentStats = {
  867. phase: 'planning',
  868. planning_total: 0,
  869. planning_completed: 0,
  870. generation_total: 0,
  871. generation_completed: 0,
  872. illustration_total: 0,
  873. illustration_completed: 0,
  874. };
  875. const contentPlans = new Map();
  876. let storedContentPlans = pruneContentGenerationPlans(fullRegenerate ? {} : storedPlan.contentGenerationPlans, leaves);
  877. let knowledgeItems = [];
  878. let allowedKnowledgeItemIds = new Set();
  879. let knowledgeContentMap = new Map();
  880. let selectedAiImageIds = new Set();
  881. let aiImageTargets = [];
  882. let mermaidImageTargets = [];
  883. let sections = createInitialSections(leaves, fullRegenerate ? {} : storedPlan.contentGenerationSections);
  884. let tasksToRun = leaves.filter(({ item }) => {
  885. const section = sections[item.id];
  886. const content = section?.content || item.content || '';
  887. return regenerate || section?.status === 'error' || !String(content).trim();
  888. });
  889. if (targetItemId) {
  890. tasksToRun = leaves.filter(({ item }) => item.id === targetItemId);
  891. if (!tasksToRun.length) {
  892. throw new Error('未找到要重新生成的正文小节');
  893. }
  894. }
  895. const taskItemIds = new Set(tasksToRun.map(({ item }) => item.id));
  896. const retainedTableCount = maxTables === null ? 0 : countRetainedTablePlans(storedContentPlans, taskItemIds);
  897. const maxTablesForRun = maxTables === null ? null : Math.max(0, maxTables - retainedTableCount);
  898. let logs = [`准备生成正文,共 ${leaves.length} 个小节。`];
  899. if (targetItemId) {
  900. logs = [`准备重新生成正文小节:${targetItemId}。`];
  901. }
  902. logs = [...logs, tableRequirement === 'heavy'
  903. ? '表格需求:大量,保持现有表格编排逻辑。'
  904. : tableRequirement === 'none'
  905. ? '表格需求:不要,本次正文编排不会安排表格。'
  906. : `表格需求:${TABLE_REQUIREMENT_LABELS[tableRequirement]},全文最多 ${maxTables} 个表格,本轮最多新增 ${maxTablesForRun} 个。`];
  907. logs = [...logs, aiImagesEnabled
  908. ? `AI 生图已启用,将在整体编排后择优生成,最多 ${maxAiImages} 张。`
  909. : 'AI 生图未启用或不可用,本次不会调用生图接口。'];
  910. logs = [...logs, mermaidImagesEnabled
  911. ? 'Mermaid 图片已启用,适合简单图示的小节会优先使用 Mermaid 图。'
  912. : 'Mermaid 图片未启用。'];
  913. if (!realTimeRender) {
  914. logs = [...logs, '实时渲染已关闭,每个小节生成完成后再刷新正文。'];
  915. }
  916. knowledgeItems = loadContentKnowledgeItems(knowledgeBaseService, referenceKnowledgeDocumentIds, (message) => {
  917. logs = [...logs, message];
  918. });
  919. allowedKnowledgeItemIds = new Set(knowledgeItems.map((item) => item.id));
  920. knowledgeContentMap = loadContentKnowledgeContentMap(knowledgeBaseService, referenceKnowledgeDocumentIds, (message) => {
  921. logs = [...logs, message];
  922. });
  923. function statsSnapshot() {
  924. contentStats.generation_completed = leaves.filter(({ item }) => ['success', 'error'].includes(sections[item.id]?.status)).length;
  925. return { images: { total: sumImageStats(imageStats.ai, imageStats.mermaid), ai: { ...imageStats.ai }, mermaid: { ...imageStats.mermaid } }, content: { ...contentStats } };
  926. }
  927. let technicalPlan = workspaceStore.updateTechnicalPlan({
  928. outlineData,
  929. contentGenerationSections: sections,
  930. contentGenerationPlans: storedContentPlans,
  931. referenceKnowledgeDocumentIds,
  932. contentGenerationTask: updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }),
  933. });
  934. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, technicalPlan);
  935. if (!tasksToRun.length) {
  936. logs = [...logs, '正文已全部生成,无需重复生成。'];
  937. technicalPlan = workspaceStore.updateTechnicalPlan({
  938. contentGenerationTask: updateTask({ status: 'success', progress: 100, logs, stats: statsSnapshot() }),
  939. });
  940. updateTask({ status: 'success', progress: 100, logs, stats: statsSnapshot() }, technicalPlan);
  941. return;
  942. }
  943. function saveSection(item, partial, contentForOutline, taskPartial = {}) {
  944. const prev = workspaceStore.loadTechnicalPlan() || {};
  945. sections = withSection(prev.contentGenerationSections || sections, item, partial);
  946. const currentOutlineData = prev.outlineData || outlineData;
  947. const outlineContent = contentForOutline ?? (sections[item.id].content || '');
  948. const nextOutlineData = {
  949. ...currentOutlineData,
  950. outline: updateOutlineItemContent(currentOutlineData.outline || outlineData.outline, item.id, outlineContent),
  951. };
  952. const saved = workspaceStore.updateTechnicalPlan({
  953. contentGenerationSections: sections,
  954. outlineData: nextOutlineData,
  955. });
  956. updateTask({ status: 'running', progress: progressFor(leaves, sections), stats: statsSnapshot(), ...taskPartial }, saved);
  957. return saved;
  958. }
  959. function illustrationTypeForSinglePlan(contentPlan) {
  960. if (contentPlan.image.needed) {
  961. return 'ai';
  962. }
  963. if (contentPlan.mermaid.needed) {
  964. return 'mermaid';
  965. }
  966. return 'none';
  967. }
  968. function applyIllustrationTargets(targets, getIllustrationType) {
  969. selectedAiImageIds = new Set();
  970. aiImageTargets = [];
  971. mermaidImageTargets = [];
  972. for (const context of targets) {
  973. const illustrationType = normalizeIllustrationType(getIllustrationType(context));
  974. if (illustrationType === 'ai') {
  975. selectedAiImageIds.add(context.item.id);
  976. aiImageTargets.push(context);
  977. } else if (illustrationType === 'mermaid') {
  978. mermaidImageTargets.push(context);
  979. }
  980. }
  981. imageStats.ai.planned = aiImageTargets.length;
  982. imageStats.mermaid.planned = mermaidImageTargets.length;
  983. }
  984. function persistContentPlans(targets, getIllustrationType) {
  985. const nextPlans = { ...storedContentPlans };
  986. for (const context of targets) {
  987. const contentPlan = contentPlans.get(context.item.id) || normalizeContentPlan({});
  988. nextPlans[context.item.id] = createStoredContentPlan(contentPlan, getIllustrationType(context));
  989. }
  990. storedContentPlans = pruneContentGenerationPlans(nextPlans, leaves);
  991. const saved = workspaceStore.updateTechnicalPlan({ contentGenerationPlans: storedContentPlans });
  992. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, saved);
  993. return saved;
  994. }
  995. async function planOne(context) {
  996. const { item, parentChapters, siblingChapters } = context;
  997. let contentPlan;
  998. try {
  999. contentPlan = await aiService.collectJsonResponse({
  1000. messages: buildChapterContentPlanMessages({
  1001. chapter: item,
  1002. parentChapters,
  1003. siblingChapters,
  1004. projectOverview,
  1005. regenerateRequirement,
  1006. tableRequirement,
  1007. maxTables,
  1008. tableTotalSections: leaves.length,
  1009. imageGenerationAvailable: aiImagesEnabled && maxAiImages > 0,
  1010. mermaidGenerationAvailable: mermaidImagesEnabled,
  1011. maxAiImages,
  1012. totalSections: tasksToRun.length,
  1013. knowledgeItems,
  1014. }),
  1015. temperature: 0.2,
  1016. progressLabel: '正文编排决策',
  1017. failureMessage: '模型返回的正文编排决策格式无效',
  1018. normalizer: (value) => normalizeContentPlan(value, allowedKnowledgeItemIds),
  1019. validator: validateContentPlan,
  1020. });
  1021. } catch (error) {
  1022. contentPlan = normalizeContentPlan({}, allowedKnowledgeItemIds);
  1023. logs = [...logs, `编排失败:${item.id} ${item.title || '未命名章节'},${error.message || '模型返回无效'},将按纯正文生成。`];
  1024. }
  1025. if (tableRequirement === 'none') {
  1026. contentPlan = clearContentPlanTable(contentPlan);
  1027. }
  1028. contentPlans.set(item.id, contentPlan);
  1029. contentStats.planning_completed += 1;
  1030. logs = [...logs, `编排完成:${item.id} ${item.title || '未命名章节'}(知识库:${contentPlan.knowledge.item_ids.length} 条,表格:${contentPlan.table.needed ? '需要' : '不需要'},Mermaid:${contentPlan.mermaid.needed ? '需要' : '不需要'},AI 图:${contentPlan.image.needed ? '需要' : '不需要'})`];
  1031. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1032. }
  1033. async function planAll() {
  1034. contentStats.phase = 'planning';
  1035. contentStats.planning_total = tasksToRun.length;
  1036. contentStats.planning_completed = 0;
  1037. contentStats.generation_total = tasksToRun.length;
  1038. logs = [...logs, `开始整体编排决策,共 ${tasksToRun.length} 个小节。`];
  1039. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1040. await runWithConcurrency(tasksToRun, concurrency, planOne);
  1041. const tableCandidates = tasksToRun.filter(({ item }) => contentPlans.get(item.id)?.table.needed);
  1042. const selectedTableIds = maxTablesForRun === null
  1043. ? new Set(tableCandidates.map(({ item }) => item.id))
  1044. : pickDistributedTableTargets(tableCandidates, maxTablesForRun);
  1045. if (maxTablesForRun !== null) {
  1046. for (const { item } of tableCandidates) {
  1047. if (!selectedTableIds.has(item.id)) {
  1048. contentPlans.set(item.id, clearContentPlanTable(contentPlans.get(item.id)));
  1049. }
  1050. }
  1051. }
  1052. const mermaidCandidates = tasksToRun.filter(({ item }) => contentPlans.get(item.id)?.mermaid.needed);
  1053. const aiImageCandidates = tasksToRun.filter(({ item }) => contentPlans.get(item.id)?.image.needed);
  1054. selectedAiImageIds = pickDistributedImageTargets(
  1055. aiImageCandidates.map((context) => ({ ...context, plan: contentPlans.get(context.item.id) })),
  1056. maxAiImages,
  1057. );
  1058. aiImageTargets = tasksToRun.filter(({ item }) => selectedAiImageIds.has(item.id));
  1059. mermaidImageTargets = mermaidCandidates.filter(({ item }) => !selectedAiImageIds.has(item.id));
  1060. imageStats.mermaid.planned = mermaidImageTargets.length;
  1061. imageStats.mermaid.skipped += Math.max(0, mermaidCandidates.length - mermaidImageTargets.length);
  1062. imageStats.ai.planned = selectedAiImageIds.size;
  1063. imageStats.ai.skipped += Math.max(0, aiImageCandidates.length - selectedAiImageIds.size);
  1064. logs = [...logs, `整体编排完成:表格候选 ${tableCandidates.length} 个,${maxTablesForRun === null ? '保持现有编排' : `入选 ${selectedTableIds.size} 个`};AI 生图候选 ${aiImageCandidates.length} 张,入选 ${selectedAiImageIds.size} 张;Mermaid 候选 ${mermaidCandidates.length} 张,执行 ${mermaidImageTargets.length} 张。`];
  1065. const mermaidImageIds = new Set(mermaidImageTargets.map(({ item }) => item.id));
  1066. persistContentPlans(tasksToRun, ({ item }) => {
  1067. if (selectedAiImageIds.has(item.id)) {
  1068. return 'ai';
  1069. }
  1070. if (mermaidImageIds.has(item.id)) {
  1071. return 'mermaid';
  1072. }
  1073. return 'none';
  1074. });
  1075. contentStats.phase = 'generating';
  1076. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1077. }
  1078. async function prepareSingleSectionPlan() {
  1079. const context = tasksToRun[0];
  1080. const storedContentPlan = normalizeStoredContentPlan(storedContentPlans[context.item.id]);
  1081. contentStats.phase = 'planning';
  1082. contentStats.planning_total = 1;
  1083. contentStats.planning_completed = 0;
  1084. contentStats.generation_total = 1;
  1085. if (storedContentPlan) {
  1086. contentPlans.set(context.item.id, storedContentPlan.plan);
  1087. contentStats.planning_completed = 1;
  1088. logs = [...logs, `复用历史编排:${context.item.id} ${context.item.title || '未命名章节'}(配图:${storedContentPlan.illustration_type})。`];
  1089. applyIllustrationTargets([context], () => storedContentPlan.illustration_type);
  1090. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1091. } else {
  1092. logs = [...logs, `未找到历史编排结果,将仅重新编排当前小节:${context.item.id} ${context.item.title || '未命名章节'}。`];
  1093. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1094. await planOne(context);
  1095. const contentPlan = contentPlans.get(context.item.id) || normalizeContentPlan({});
  1096. const illustrationType = illustrationTypeForSinglePlan(contentPlan);
  1097. applyIllustrationTargets([context], () => illustrationType);
  1098. persistContentPlans([context], () => illustrationType);
  1099. logs = [...logs, `当前小节编排已保存:${context.item.id} ${context.item.title || '未命名章节'}(配图:${illustrationType})。`];
  1100. }
  1101. contentStats.phase = 'generating';
  1102. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1103. }
  1104. async function runOne(context) {
  1105. const { item, parentChapters, siblingChapters } = context;
  1106. const previousSection = sections[item.id] || {};
  1107. const previousContent = previousSection.content || item.content || '';
  1108. const isSingleSectionRegeneration = Boolean(targetItemId);
  1109. let rawContent = regenerate ? '' : previousContent;
  1110. let content = stripRepeatedChapterTitle(normalizeGeneratedMarkdown(rawContent), item);
  1111. logs = [...logs, `开始生成:${item.id} ${item.title || '未命名章节'}`];
  1112. saveSection(item, {
  1113. status: 'running',
  1114. content: isSingleSectionRegeneration ? previousContent : content,
  1115. error: undefined,
  1116. }, isSingleSectionRegeneration ? previousContent : content, { logs });
  1117. try {
  1118. const contentPlan = contentPlans.get(item.id) || normalizeContentPlan({});
  1119. const knowledgeContents = resolveKnowledgeContents(contentPlan.knowledge?.item_ids, knowledgeContentMap);
  1120. await aiService.streamChat({
  1121. messages: buildChapterContentMessages({ chapter: item, parentChapters, siblingChapters, projectOverview, regenerateRequirement, contentPlan, knowledgeContents }),
  1122. temperature: 0.7,
  1123. }, (event) => {
  1124. if (event.type !== 'chunk' || !event.chunk) {
  1125. return;
  1126. }
  1127. rawContent += event.chunk;
  1128. content = stripRepeatedChapterTitle(normalizeGeneratedMarkdown(rawContent), item);
  1129. if (realTimeRender && !isSingleSectionRegeneration) {
  1130. saveSection(item, { status: 'running', content, error: undefined }, content);
  1131. }
  1132. });
  1133. content = stripRepeatedChapterTitle(normalizeGeneratedMarkdown(rawContent), item);
  1134. logs = [...logs, `生成完成:${item.id} ${item.title || '未命名章节'}`];
  1135. saveSection(item, { status: 'success', content, error: undefined }, content, { logs });
  1136. } catch (error) {
  1137. const message = error.message || '正文生成失败';
  1138. logs = [...logs, `生成失败:${item.id} ${item.title || '未命名章节'},${message}${isSingleSectionRegeneration ? '。已保留原正文。' : ''}`];
  1139. saveSection(item, {
  1140. status: 'error',
  1141. content: isSingleSectionRegeneration ? previousContent : content,
  1142. error: message,
  1143. }, isSingleSectionRegeneration ? previousContent : content, { logs });
  1144. }
  1145. }
  1146. function getCurrentSuccessfulContent(item) {
  1147. const currentPlan = workspaceStore.loadTechnicalPlan() || {};
  1148. const currentSections = currentPlan.contentGenerationSections || sections;
  1149. const section = currentSections[item.id] || {};
  1150. return section.status === 'success' ? String(section.content || '') : '';
  1151. }
  1152. async function runAiIllustration(context) {
  1153. const { item } = context;
  1154. const contentPlan = contentPlans.get(item.id) || normalizeContentPlan({});
  1155. const baseContent = getCurrentSuccessfulContent(item);
  1156. if (!baseContent.trim()) {
  1157. imageStats.ai.skipped += 1;
  1158. contentStats.illustration_completed += 1;
  1159. logs = [...logs, `跳过 AI 配图:${item.id} ${item.title || '未命名章节'},正文未成功生成。`];
  1160. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1161. return;
  1162. }
  1163. imageStats.ai.attempted += 1;
  1164. logs = [...logs, `开始 AI 配图:${item.id} ${contentPlan.image.title}`];
  1165. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1166. try {
  1167. const generatedImage = await aiService.generateImage({
  1168. title: contentPlan.image.title,
  1169. prompt: contentPlan.image.prompt,
  1170. style: contentPlan.image.style,
  1171. });
  1172. const content = appendGeneratedImageMarkdown(baseContent, contentPlan.image, generatedImage);
  1173. imageStats.ai.success += 1;
  1174. contentStats.illustration_completed += 1;
  1175. logs = [...logs, `AI 配图完成:${item.id} ${contentPlan.image.title}`];
  1176. saveSection(item, { status: 'success', content, error: undefined }, content, { logs });
  1177. } catch (imageError) {
  1178. imageStats.ai.failed += 1;
  1179. contentStats.illustration_completed += 1;
  1180. logs = [...logs, `AI 配图失败:${item.id} ${contentPlan.image.title},${imageError.message || '生图失败'},已保留正文。`];
  1181. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1182. }
  1183. }
  1184. async function runMermaidIllustration(context) {
  1185. const { item } = context;
  1186. const contentPlan = contentPlans.get(item.id) || normalizeContentPlan({});
  1187. const baseContent = getCurrentSuccessfulContent(item);
  1188. if (!baseContent.trim()) {
  1189. imageStats.mermaid.skipped += 1;
  1190. contentStats.illustration_completed += 1;
  1191. logs = [...logs, `跳过 Mermaid 配图:${item.id} ${item.title || '未命名章节'},正文未成功生成。`];
  1192. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1193. return;
  1194. }
  1195. imageStats.mermaid.attempted += 1;
  1196. logs = [...logs, `开始校验 Mermaid 配图:${item.id} ${contentPlan.mermaid.title}`];
  1197. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1198. const mermaidResult = await prepareRenderableMermaidPlan({
  1199. aiService,
  1200. context,
  1201. projectOverview,
  1202. regenerateRequirement,
  1203. mermaidPlan: contentPlan.mermaid,
  1204. });
  1205. if (mermaidResult.ok) {
  1206. const content = appendMermaidImageMarkdown(baseContent, mermaidResult.plan);
  1207. imageStats.mermaid.success += 1;
  1208. contentStats.illustration_completed += 1;
  1209. logs = [...logs, mermaidResult.attempts > 0
  1210. ? `Mermaid 配图已修复并完成:${item.id} ${mermaidResult.plan.title}(修复 ${mermaidResult.attempts} 轮)`
  1211. : `Mermaid 配图完成:${item.id} ${mermaidResult.plan.title}`];
  1212. saveSection(item, { status: 'success', content, error: undefined }, content, { logs });
  1213. } else {
  1214. imageStats.mermaid.failed += 1;
  1215. contentStats.illustration_completed += 1;
  1216. logs = [...logs, `Mermaid 配图取消:${item.id} ${contentPlan.mermaid.title},连续修复 ${MERMAID_REPAIR_ATTEMPTS} 轮失败,${mermaidResult.error || '渲染失败'},已保留正文。`];
  1217. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1218. }
  1219. }
  1220. async function runIllustrations() {
  1221. const illustrationTotal = aiImageTargets.length + mermaidImageTargets.length;
  1222. contentStats.phase = 'illustrating';
  1223. contentStats.illustration_total = illustrationTotal;
  1224. contentStats.illustration_completed = 0;
  1225. logs = [...logs, illustrationTotal
  1226. ? `开始配图:AI 生图 ${aiImageTargets.length} 张(并发 ${AI_IMAGE_CONCURRENCY}),Mermaid 图 ${mermaidImageTargets.length} 张(并发 ${MERMAID_IMAGE_CONCURRENCY})。`
  1227. : '本次没有需要执行的配图。'];
  1228. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1229. if (!illustrationTotal) {
  1230. return;
  1231. }
  1232. await Promise.all([
  1233. runWithConcurrency(aiImageTargets, AI_IMAGE_CONCURRENCY, runAiIllustration),
  1234. runWithConcurrency(mermaidImageTargets, MERMAID_IMAGE_CONCURRENCY, runMermaidIllustration),
  1235. ]);
  1236. logs = [...logs, '配图阶段完成。'];
  1237. updateTask({ status: 'running', progress: progressFor(leaves, sections), logs, stats: statsSnapshot() }, workspaceStore.loadTechnicalPlan());
  1238. }
  1239. if (targetItemId) {
  1240. await prepareSingleSectionPlan();
  1241. } else {
  1242. await planAll();
  1243. }
  1244. await runWithConcurrency(tasksToRun, concurrency, runOne);
  1245. await runIllustrations();
  1246. const failedCount = leaves.filter(({ item }) => sections[item.id]?.status === 'error').length;
  1247. const finalProgress = progressFor(leaves, sections);
  1248. const finalStatus = taskStatusFor(leaves, sections);
  1249. contentStats.phase = 'done';
  1250. logs = [...logs, targetItemId
  1251. ? (failedCount ? `小节重新生成结束,当前整体进度 ${finalProgress}%,${failedCount} 个小节失败。` : `小节重新生成完成,当前整体进度 ${finalProgress}%。`)
  1252. : (failedCount ? `正文生成完成,${failedCount} 个小节失败。` : '正文生成完成。')];
  1253. technicalPlan = workspaceStore.updateTechnicalPlan({
  1254. contentGenerationSections: sections,
  1255. contentGenerationPlans: storedContentPlans,
  1256. contentGenerationTask: updateTask({ status: finalStatus, progress: finalProgress, logs, stats: statsSnapshot() }),
  1257. });
  1258. updateTask({ status: finalStatus, progress: finalProgress, logs, stats: statsSnapshot() }, technicalPlan);
  1259. }
  1260. module.exports = { runContentGenerationTask, stripRepeatedChapterTitle };