770 lines
26 KiB
TypeScript
770 lines
26 KiB
TypeScript
// @ts-nocheck
|
||
|
||
|
||
import { render as renderMarkdown } from '../reports/markdown.js';
|
||
import { stripReportMarkers } from '../reports/report-clean.js';
|
||
import { getCurrentModelInfo } from '../utils/config.js';
|
||
import { getState as getSessionState } from '../core/session-context.js';
|
||
import { resolveAppNamesFromToolNames } from './sidebar.js';
|
||
|
||
const MIN_CHARS = 200;
|
||
const TABLE_RE = /\|[^\n]+\|[\r\n]+\|[-:\s|]+\|/;
|
||
|
||
const PAGE_WIDTH_DXA = 11906;
|
||
const PAGE_MARGIN_X_DXA = 540;
|
||
const CONTENT_WIDTH_DXA = PAGE_WIDTH_DXA - PAGE_MARGIN_X_DXA * 2;
|
||
|
||
function formatExportModelLabel() {
|
||
try {
|
||
const info = getCurrentModelInfo();
|
||
const source = String(info?.sourceLabel || '').trim() || '模型';
|
||
const model = String(info?.model || '').trim();
|
||
if (!model) return source;
|
||
return `${source}-${model}`;
|
||
} catch (_e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
export function collectToolNamesFromMessages(messages) {
|
||
const list = Array.isArray(messages) ? messages : [];
|
||
const names = [];
|
||
for (let i = list.length - 1; i >= 0; i--) {
|
||
const m = list[i];
|
||
if (m?.role === 'user') break;
|
||
if (Array.isArray(m?.toolCalls)) {
|
||
for (const tc of m.toolCalls) {
|
||
if (tc?.name) names.push(tc.name);
|
||
}
|
||
}
|
||
}
|
||
return names.reverse();
|
||
}
|
||
|
||
function collectToolNamesFromDom(anchorEl) {
|
||
const names = [];
|
||
const msg = anchorEl?.closest?.('.msg');
|
||
if (!msg?.parentElement) return names;
|
||
const siblings = [...msg.parentElement.children];
|
||
const idx = siblings.indexOf(msg);
|
||
for (let i = idx; i >= 0; i--) {
|
||
const el = siblings[i];
|
||
if (i < idx && el.classList?.contains('msg') && el.classList.contains('user')) break;
|
||
for (const n of el.querySelectorAll?.('.tool-name[title]') || []) {
|
||
const t = n.getAttribute('title');
|
||
if (t) names.push(t);
|
||
}
|
||
}
|
||
return names;
|
||
}
|
||
|
||
function inferAppNamesFromMarkdown(markdown) {
|
||
const raw = String(markdown || '');
|
||
if (/个人.*年度报告|班组.*年度报告|profile_/i.test(raw)) return ['Profile'];
|
||
if (/运营图|轮乘|交接|排班|metro_/i.test(raw)) return ['Assignment'];
|
||
return [];
|
||
}
|
||
|
||
function resolveExportAppNames(markdown, opts = {}) {
|
||
if (Array.isArray(opts.appNames) && opts.appNames.length) {
|
||
return opts.appNames.map((s) => String(s).trim()).filter(Boolean);
|
||
}
|
||
const fromTools = resolveAppNamesFromToolNames(opts.toolNames || []);
|
||
if (fromTools.length) return fromTools;
|
||
const fromDom = resolveAppNamesFromToolNames(collectToolNamesFromDom(opts.anchorEl));
|
||
if (fromDom.length) return fromDom;
|
||
return inferAppNamesFromMarkdown(markdown);
|
||
}
|
||
|
||
function footerCreditLine(markdown, opts = {}) {
|
||
const model = formatExportModelLabel();
|
||
const apps = resolveExportAppNames(markdown, opts);
|
||
const appPart = apps.length
|
||
? `根据${apps.join('、')}业务数据生成`
|
||
: '根据业务数据生成';
|
||
const operator = getProfileUser();
|
||
const operatorPart = operator ? ` - 操作员:${operator}` : '';
|
||
return model
|
||
? `由 ccSparkle Agent ${appPart} - 大模型:${model}${operatorPart}。`
|
||
: `由 ccSparkle Agent ${appPart}${operatorPart}。`;
|
||
}
|
||
|
||
let _profileUser = null;
|
||
export function setProfileUser(user) {
|
||
if (!user) return;
|
||
if (typeof user === 'string') { _profileUser = user; return; }
|
||
if (user.name) _profileUser = user.name;
|
||
}
|
||
export function getProfileUser() {
|
||
return _profileUser;
|
||
}
|
||
|
||
let _libsPromise = null;
|
||
|
||
function loadScript(src) {
|
||
return new Promise((resolve, reject) => {
|
||
const abs = new URL(src, import.meta.url).href;
|
||
const existed = [...document.scripts].find(s => s.src === abs);
|
||
if (existed) {
|
||
if (existed.dataset.loaded === '1') return resolve();
|
||
existed.addEventListener('load', () => resolve(), { once: true });
|
||
existed.addEventListener('error', () => reject(new Error(`加载失败: ${src}`)), { once: true });
|
||
return;
|
||
}
|
||
const el = document.createElement('script');
|
||
el.src = abs;
|
||
el.async = true;
|
||
el.onload = () => { el.dataset.loaded = '1'; resolve(); };
|
||
el.onerror = () => reject(new Error(`加载失败: ${src}`));
|
||
document.head.appendChild(el);
|
||
});
|
||
}
|
||
|
||
async function ensureLibs() {
|
||
if (!_libsPromise) {
|
||
_libsPromise = (async () => {
|
||
await loadScript('./vendor/docx.umd.cjs');
|
||
if (!window.docx?.Document || !window.docx?.Packer) {
|
||
throw new Error('docx 库未就绪');
|
||
}
|
||
})().catch(e => {
|
||
_libsPromise = null;
|
||
throw e;
|
||
});
|
||
}
|
||
return _libsPromise;
|
||
}
|
||
|
||
export function shouldShowExport(markdown) {
|
||
const text = String(markdown || '').trim();
|
||
if (!text) return false;
|
||
if (TABLE_RE.test(text)) return true;
|
||
const compact = text.replace(/\s+/g, '');
|
||
return compact.length >= MIN_CHARS;
|
||
}
|
||
|
||
export function extractFilename(markdown) {
|
||
const raw = String(markdown || '');
|
||
const cell = (label) => {
|
||
const re = new RegExp(`\\|\\s*${label}\\s*\\|\\s*([^|\\n]+)\\s*\\|`);
|
||
const m = raw.match(re);
|
||
if (!m) return '';
|
||
const v = m[1].replace(/[*_`]/g, '').trim();
|
||
return (!v || v === '—') ? '' : v;
|
||
};
|
||
|
||
const yearOnly = (y) => {
|
||
const s = String(y || '').trim();
|
||
if (!s) return '';
|
||
const m = s.match(/(\d{4})/);
|
||
return m ? m[1] : s.replace(/年$/, '');
|
||
};
|
||
|
||
|
||
const teamTitleSubject = (name) => {
|
||
const s = String(name || '').trim();
|
||
if (!s) return '';
|
||
return /班组$/.test(s) ? s : `${s}班组`;
|
||
};
|
||
|
||
|
||
let title = (raw.match(/^#\s+([^#\n]+)$/m) || [])[1]?.trim() || '';
|
||
|
||
|
||
if (!title || /^(班组概况|基本信息|AI分析报告|暂无数据|个人画像报告|班组画像报告)$/.test(title)) {
|
||
let subject = '';
|
||
let kind = '';
|
||
const team = cell('班组');
|
||
if (team) {
|
||
subject = team;
|
||
kind = 'team';
|
||
} else {
|
||
const name = cell('姓名');
|
||
if (name) {
|
||
subject = name;
|
||
kind = 'person';
|
||
}
|
||
}
|
||
if (!subject) {
|
||
try {
|
||
const st = getSessionState() || {};
|
||
if (st.currentTeam) {
|
||
subject = String(st.currentTeam).trim();
|
||
kind = 'team';
|
||
} else if (st.currentEmployeeName) {
|
||
subject = String(st.currentEmployeeName).trim();
|
||
kind = 'person';
|
||
}
|
||
} catch (_e) { }
|
||
}
|
||
let year = yearOnly(cell('年份'));
|
||
if (!year) {
|
||
try {
|
||
const y = getSessionState()?.currentYear;
|
||
if (y != null && y !== '') year = yearOnly(y);
|
||
} catch (_e) { }
|
||
}
|
||
if (subject && kind === 'person') {
|
||
title = `${subject}个人${year || ''}年度报告`;
|
||
} else if (subject && kind === 'team') {
|
||
title = `${teamTitleSubject(subject)}${year || ''}年度报告`;
|
||
}
|
||
}
|
||
|
||
if (!title) title = 'AI分析报告';
|
||
title = title
|
||
.replace(/[*_`#\[\]()]/g, '')
|
||
.replace(/[\\/:*?"<>|]+/g, '_')
|
||
.replace(/\s+/g, '_')
|
||
.replace(/·/g, '_')
|
||
.replace(/_+/g, '_')
|
||
.replace(/^_|_$/g, '')
|
||
.slice(0, 80) || 'AI分析报告';
|
||
const d = new Date();
|
||
const stamp = [
|
||
d.getFullYear(),
|
||
String(d.getMonth() + 1).padStart(2, '0'),
|
||
String(d.getDate()).padStart(2, '0'),
|
||
].join('');
|
||
return `${title}_${stamp}`;
|
||
}
|
||
|
||
function downloadBlob(blob, filename) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
}
|
||
|
||
function cellBorders() {
|
||
const { BorderStyle } = window.docx;
|
||
const b = { style: BorderStyle.SINGLE, size: 4, color: '8FA8C8' };
|
||
return { top: b, bottom: b, left: b, right: b };
|
||
}
|
||
|
||
function cellWeight(text) {
|
||
const s = String(text || '');
|
||
let w = 0;
|
||
for (const ch of s) {
|
||
w += /[\u3400-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch) ? 2 : 1;
|
||
}
|
||
return Math.max(w, 2);
|
||
}
|
||
|
||
function estimateColumnWidths(trs, colCount, tableWidthDxa) {
|
||
const weights = Array.from({ length: colCount }, () => 2);
|
||
for (const tr of trs) {
|
||
const cells = [...tr.children].filter(c => /^(td|th)$/i.test(c.tagName));
|
||
for (let i = 0; i < colCount; i++) {
|
||
const t = cells[i] ? textOf(cells[i]) : '';
|
||
weights[i] = Math.max(weights[i], cellWeight(t));
|
||
}
|
||
}
|
||
|
||
const minW = Math.max(720, Math.floor(tableWidthDxa / (colCount * 4)));
|
||
const maxW = Math.floor(tableWidthDxa * (colCount <= 2 ? 0.72 : 0.58));
|
||
const sum = weights.reduce((a, b) => a + b, 0) || colCount;
|
||
let widths = weights.map((w) => {
|
||
const raw = Math.floor((tableWidthDxa * w) / sum);
|
||
return Math.min(maxW, Math.max(minW, raw));
|
||
});
|
||
let total = widths.reduce((a, b) => a + b, 0);
|
||
|
||
if (total !== tableWidthDxa && total > 0) {
|
||
const scale = tableWidthDxa / total;
|
||
widths = widths.map((w, i) => (
|
||
i === colCount - 1 ? 0 : Math.max(minW, Math.floor(w * scale))
|
||
));
|
||
const used = widths.slice(0, -1).reduce((a, b) => a + b, 0);
|
||
widths[colCount - 1] = Math.max(minW, tableWidthDxa - used);
|
||
}
|
||
return widths;
|
||
}
|
||
|
||
function columnWidthsToPct(widthsDxA) {
|
||
const total = widthsDxA.reduce((a, b) => a + b, 0) || 1;
|
||
const pcts = widthsDxA.map((w) => Math.max(1, Math.floor((100 * w) / total)));
|
||
const used = pcts.slice(0, -1).reduce((a, b) => a + b, 0);
|
||
pcts[pcts.length - 1] = Math.max(1, 100 - used);
|
||
return pcts;
|
||
}
|
||
|
||
function pctFiftieths(percent0to100) {
|
||
const n = Math.max(1, Math.min(5000, Math.round(Number(percent0to100) * 50)));
|
||
return String(n);
|
||
}
|
||
|
||
function isAnalysisKicker(text) {
|
||
return /^(总体表现|主要问题|改进建议)\s*[::]?$/.test(String(text || '').trim());
|
||
}
|
||
|
||
function inlineToRuns(node, base = {}) {
|
||
const { TextRun } = window.docx;
|
||
const runs = [];
|
||
|
||
const walk = (n, style) => {
|
||
if (!n) return;
|
||
if (n.nodeType === Node.TEXT_NODE) {
|
||
const t = n.textContent || '';
|
||
if (!t) return;
|
||
|
||
const parts = t.split('\n');
|
||
parts.forEach((part, i) => {
|
||
if (part) runs.push(new TextRun({ text: part, ...style }));
|
||
if (i < parts.length - 1) runs.push(new TextRun({ break: 1 }));
|
||
});
|
||
return;
|
||
}
|
||
if (n.nodeType !== Node.ELEMENT_NODE) return;
|
||
const tag = n.tagName.toLowerCase();
|
||
if (tag === 'br') {
|
||
runs.push(new TextRun({ break: 1 }));
|
||
return;
|
||
}
|
||
const next = { ...style };
|
||
if (tag === 'strong' || tag === 'b') next.bold = true;
|
||
if (tag === 'em' || tag === 'i') next.italics = true;
|
||
if (tag === 'code') {
|
||
next.font = 'Consolas';
|
||
next.size = 18;
|
||
}
|
||
if (tag === 'a') {
|
||
|
||
next.color = '2c5aa0';
|
||
next.underline = {};
|
||
}
|
||
for (const child of n.childNodes) walk(child, next);
|
||
};
|
||
|
||
walk(node, { font: 'Microsoft YaHei', size: 22, ...base });
|
||
if (!runs.length) runs.push(new TextRun({ text: '', font: 'Microsoft YaHei', size: 22 }));
|
||
return runs;
|
||
}
|
||
|
||
function textOf(el) {
|
||
return (el?.textContent || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function pushParagraph(children, opts) {
|
||
const { Paragraph } = window.docx;
|
||
children.push(new Paragraph(opts));
|
||
}
|
||
|
||
function htmlElementToBlocks(el, out, listCtx = { ordered: false, index: 0 }) {
|
||
const {
|
||
Paragraph, TextRun, Table, TableRow, TableCell,
|
||
WidthType, AlignmentType,
|
||
} = window.docx;
|
||
if (!el || el.nodeType !== Node.ELEMENT_NODE) return;
|
||
|
||
const tag = el.tagName.toLowerCase();
|
||
|
||
if (tag === 'h1' || tag === 'h2' || tag === 'h3') {
|
||
|
||
const size = tag === 'h1' ? 32 : tag === 'h2' ? 26 : 24;
|
||
const color = tag === 'h1' ? '1a4a8a' : tag === 'h2' ? '2c5aa0' : '3d6fa8';
|
||
const border = tag === 'h2'
|
||
? { bottom: { style: window.docx.BorderStyle.SINGLE, size: 8, color: '2c5aa0', space: 4 } }
|
||
: tag === 'h3'
|
||
? { bottom: { style: window.docx.BorderStyle.SINGLE, size: 4, color: 'A8C4E0', space: 2 } }
|
||
: undefined;
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { before: tag === 'h1' ? 280 : tag === 'h2' ? 220 : 160, after: 100 },
|
||
border,
|
||
children: inlineToRuns(el, { bold: true, size, color }),
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (tag === 'p') {
|
||
const plain = textOf(el);
|
||
const kicker = isAnalysisKicker(plain)
|
||
|| el.classList?.contains('analysis-kicker');
|
||
if (kicker) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { before: 140, after: 40 },
|
||
children: [new TextRun({
|
||
text: plain.replace(/[::]\s*$/, ''),
|
||
bold: true,
|
||
size: 22,
|
||
color: '2c5aa0',
|
||
font: 'Microsoft YaHei',
|
||
})],
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const split = plain.match(/^(总体表现|主要问题|改进建议)\s*[::]\s*(.+)$/);
|
||
if (split) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { before: 140, after: 40 },
|
||
children: [new TextRun({
|
||
text: split[1],
|
||
bold: true,
|
||
size: 22,
|
||
color: '2c5aa0',
|
||
font: 'Microsoft YaHei',
|
||
})],
|
||
}));
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 100 },
|
||
children: [new TextRun({
|
||
text: split[2],
|
||
font: 'Microsoft YaHei',
|
||
size: 22,
|
||
})],
|
||
}));
|
||
return;
|
||
}
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 100 },
|
||
children: inlineToRuns(el),
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (tag === 'blockquote') {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 120 },
|
||
indent: { left: 420 },
|
||
border: {
|
||
left: { style: window.docx.BorderStyle.SINGLE, size: 12, color: '2c5aa0', space: 8 },
|
||
},
|
||
children: inlineToRuns(el, { italics: true, color: '444444' }),
|
||
}));
|
||
return;
|
||
}
|
||
|
||
if (tag === 'ul' || tag === 'ol') {
|
||
const ordered = tag === 'ol';
|
||
let i = 0;
|
||
for (const li of el.children) {
|
||
if (li.tagName?.toLowerCase() !== 'li') continue;
|
||
i++;
|
||
const prefix = ordered ? `${i}. ` : '• ';
|
||
const runs = [
|
||
new TextRun({ text: prefix, font: 'Microsoft YaHei', size: 22 }),
|
||
...inlineToRuns(li),
|
||
];
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 60 },
|
||
indent: { left: 360 },
|
||
children: runs,
|
||
}));
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (tag === 'pre') {
|
||
const code = el.textContent || '';
|
||
for (const line of code.split('\n')) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 0 },
|
||
children: [new TextRun({
|
||
text: line || ' ',
|
||
font: 'Consolas',
|
||
size: 18,
|
||
})],
|
||
}));
|
||
}
|
||
out.push(new Paragraph({ children: [] }));
|
||
return;
|
||
}
|
||
|
||
if (tag === 'table') {
|
||
const rows = [];
|
||
const trs = [...el.querySelectorAll('tr')];
|
||
let colCount = 1;
|
||
for (const tr of trs) {
|
||
const cells = [...tr.children].filter(c => /^(td|th)$/i.test(c.tagName));
|
||
colCount = Math.max(colCount, cells.length || 1);
|
||
}
|
||
|
||
const columnWidths = estimateColumnWidths(trs, colCount, CONTENT_WIDTH_DXA);
|
||
const columnPcts = columnWidthsToPct(columnWidths);
|
||
const borders = cellBorders();
|
||
const { TableLayoutType, ShadingType } = window.docx;
|
||
|
||
for (const tr of trs) {
|
||
const cells = [...tr.children].filter(c => /^(td|th)$/i.test(c.tagName));
|
||
const isHeader = cells.some(c => c.tagName.toLowerCase() === 'th');
|
||
const rowCells = [];
|
||
for (let i = 0; i < colCount; i++) {
|
||
const cell = cells[i];
|
||
const headerCell = isHeader || cell?.tagName?.toLowerCase() === 'th';
|
||
const runs = cell
|
||
? inlineToRuns(cell, headerCell
|
||
? { bold: true, size: 18, color: '1a3a5c' }
|
||
: { size: 18, color: '222222' })
|
||
: [new TextRun({ text: ' ', size: 18 })];
|
||
const bodyFill = (rows.length % 2 === 1) ? 'F3F7FC' : 'FFFFFF';
|
||
rowCells.push(new TableCell({
|
||
borders,
|
||
width: { size: pctFiftieths(columnPcts[i]), type: WidthType.PERCENTAGE },
|
||
margins: { top: 50, bottom: 50, left: 70, right: 70 },
|
||
shading: headerCell
|
||
? { type: ShadingType.CLEAR, fill: 'D6E4F5' }
|
||
: { type: ShadingType.CLEAR, fill: bodyFill },
|
||
children: [new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 0 },
|
||
children: runs,
|
||
})],
|
||
}));
|
||
}
|
||
rows.push(new TableRow({ children: rowCells }));
|
||
}
|
||
if (rows.length) {
|
||
out.push(new Table({
|
||
width: { size: pctFiftieths(100), type: WidthType.PERCENTAGE },
|
||
columnWidths,
|
||
layout: TableLayoutType.FIXED,
|
||
rows,
|
||
}));
|
||
out.push(new Paragraph({ children: [] }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
|
||
if (tag === 'div' || tag === 'section' || tag === 'article' || tag === 'body' || tag === 'span') {
|
||
|
||
if (tag === 'span' && ![...el.children].some(c => /^(h\d|p|ul|ol|table|div|pre|blockquote)$/i.test(c.tagName))) {
|
||
if (textOf(el)) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 120 },
|
||
children: inlineToRuns(el),
|
||
}));
|
||
}
|
||
return;
|
||
}
|
||
for (const child of el.childNodes) {
|
||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||
htmlElementToBlocks(child, out, listCtx);
|
||
} else if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 80 },
|
||
children: [new TextRun({
|
||
text: child.textContent.trim(),
|
||
font: 'Microsoft YaHei',
|
||
size: 22,
|
||
})],
|
||
}));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
|
||
if (textOf(el)) {
|
||
out.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
spacing: { after: 100 },
|
||
children: inlineToRuns(el),
|
||
}));
|
||
}
|
||
}
|
||
|
||
function buildDocChildren(markdown, opts = {}) {
|
||
const { Paragraph, TextRun, AlignmentType } = window.docx;
|
||
const titleMatch = String(markdown || '').match(/^#{1,6}\s+(.+)$/m);
|
||
const title = titleMatch ? titleMatch[1].trim() : 'AI 分析报告';
|
||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||
const bodyHtml = renderMarkdown(markdown);
|
||
const credit = footerCreditLine(markdown, opts);
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.innerHTML = `
|
||
<div class="doc-header">
|
||
<p><strong>ccSparkle Agent · 智能体中台</strong></p>
|
||
<p>导出时间: ${now.replace(/</g, '')}</p>
|
||
</div>
|
||
<div class="doc-body">${bodyHtml}</div>
|
||
<div class="doc-footer"><p>${credit.replace(/</g, '')}</p></div>
|
||
`;
|
||
|
||
const children = [];
|
||
|
||
children.push(new Paragraph({
|
||
spacing: { after: 60 },
|
||
children: [new TextRun({
|
||
text: 'ccSparkle Agent · 智能体中台',
|
||
bold: true,
|
||
color: '2c5aa0',
|
||
font: 'Microsoft YaHei',
|
||
size: 24,
|
||
})],
|
||
}));
|
||
children.push(new Paragraph({
|
||
spacing: { after: 200 },
|
||
border: {
|
||
bottom: { style: window.docx.BorderStyle.SINGLE, size: 12, color: '2c5aa0', space: 4 },
|
||
},
|
||
children: [new TextRun({
|
||
text: `导出时间: ${now} · ${title}`,
|
||
color: '666666',
|
||
font: 'Microsoft YaHei',
|
||
size: 18,
|
||
})],
|
||
}));
|
||
|
||
const body = wrap.querySelector('.doc-body');
|
||
if (body) htmlElementToBlocks(body, children);
|
||
|
||
children.push(new Paragraph({
|
||
spacing: { before: 300 },
|
||
border: {
|
||
top: { style: window.docx.BorderStyle.SINGLE, size: 6, color: 'CCCCCC', space: 8 },
|
||
},
|
||
children: [new TextRun({
|
||
text: credit,
|
||
color: '888888',
|
||
font: 'Microsoft YaHei',
|
||
size: 16,
|
||
})],
|
||
}));
|
||
|
||
|
||
if (children.length === 0) {
|
||
children.push(new Paragraph({
|
||
alignment: AlignmentType.LEFT,
|
||
children: [new TextRun({ text: '(无内容)', font: 'Microsoft YaHei' })],
|
||
}));
|
||
}
|
||
return children;
|
||
}
|
||
|
||
export async function exportWord(markdown, opts = {}) {
|
||
await ensureLibs();
|
||
const { Document, Packer } = window.docx;
|
||
const cleanMd = stripReportMarkers(markdown);
|
||
const baseName = extractFilename(cleanMd || markdown);
|
||
const suffix = opts.filenameSuffix ? `-${opts.filenameSuffix}` : '';
|
||
const filename = `${baseName}${suffix}.docx`;
|
||
const doc = new Document({
|
||
creator: 'ccSparkle Agent',
|
||
title: baseName + suffix,
|
||
sections: [{
|
||
properties: {
|
||
page: {
|
||
|
||
size: { width: PAGE_WIDTH_DXA, height: 16838 },
|
||
margin: {
|
||
top: 720,
|
||
right: PAGE_MARGIN_X_DXA,
|
||
bottom: 720,
|
||
left: PAGE_MARGIN_X_DXA,
|
||
},
|
||
},
|
||
},
|
||
children: buildDocChildren(cleanMd || markdown, opts),
|
||
}],
|
||
});
|
||
const blob = await Packer.toBlob(doc);
|
||
downloadBlob(blob, filename);
|
||
}
|
||
|
||
export function mountExportBar(assistantTextEl, markdown, opts = {}) {
|
||
if (!assistantTextEl) return;
|
||
const old = assistantTextEl.querySelector('.export-actions');
|
||
if (old) old.remove();
|
||
|
||
const cleanMd = stripReportMarkers(markdown);
|
||
if (!shouldShowExport(cleanMd || markdown)) return;
|
||
|
||
const toolNames = Array.isArray(opts.toolNames) && opts.toolNames.length
|
||
? opts.toolNames
|
||
: collectToolNamesFromMessages(opts.messages);
|
||
const appNames = Array.isArray(opts.appNames) && opts.appNames.length
|
||
? opts.appNames
|
||
: resolveAppNamesFromToolNames(toolNames);
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'export-actions';
|
||
bar.innerHTML = `
|
||
<button type="button" class="export-btn" data-export="word" title="导出为 Word">导出 Word</button>
|
||
<span class="export-status" hidden></span>
|
||
`;
|
||
assistantTextEl.appendChild(bar);
|
||
|
||
const statusEl = bar.querySelector('.export-status');
|
||
const setStatus = (text, isError = false) => {
|
||
if (!statusEl) return;
|
||
if (!text) {
|
||
statusEl.hidden = true;
|
||
statusEl.textContent = '';
|
||
return;
|
||
}
|
||
statusEl.hidden = false;
|
||
statusEl.textContent = text;
|
||
statusEl.classList.toggle('error', !!isError);
|
||
};
|
||
|
||
bar.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-export]');
|
||
if (!btn || btn.disabled) return;
|
||
const exportType = btn.dataset.export;
|
||
const buttons = [...bar.querySelectorAll('.export-btn')];
|
||
buttons.forEach(b => { b.disabled = true; });
|
||
try {
|
||
if (exportType === 'raw-thinking') {
|
||
setStatus('正在生成原文+思考...');
|
||
|
||
let raw = opts.rawMarkdown;
|
||
if (!raw && opts.messages) {
|
||
raw = opts.messages
|
||
.filter(m => m?.role === 'tool' && m?.content)
|
||
.map(m => String(m.content))
|
||
.join('\n\n---\n\n');
|
||
}
|
||
if (!raw) raw = cleanMd || markdown;
|
||
|
||
raw = raw.replace(/<!--\s*HINT\s*:[\s\S]*?-->/gi, '').replace(/\n{3,}/g, '\n\n').trim();
|
||
|
||
let thinking = opts.thinkingMd;
|
||
if (!thinking && opts.messages) {
|
||
const parts = opts.messages
|
||
.filter(m => m?.role === 'assistant' && m?.thinking)
|
||
.map(m => String(m.thinking));
|
||
thinking = parts.length ? parts.join('\n\n---\n\n') : '';
|
||
}
|
||
const thinkingSection = thinking
|
||
? `\n\n---\n\n# AI 思考过程\n\n${thinking}`
|
||
: '';
|
||
await exportWord(`${raw}${thinkingSection}`, {
|
||
appNames,
|
||
toolNames,
|
||
anchorEl: assistantTextEl,
|
||
filenameSuffix: '原文+思考',
|
||
});
|
||
setStatus(thinking ? '已导出(原文 + 思考)' : '已导出(原文,本次无思考)');
|
||
} else {
|
||
setStatus('正在生成 Word...');
|
||
await exportWord(cleanMd || markdown, {
|
||
appNames,
|
||
toolNames,
|
||
anchorEl: assistantTextEl,
|
||
});
|
||
}
|
||
setTimeout(() => setStatus(''), 2500);
|
||
} catch (err) {
|
||
console.error('[export]', err);
|
||
setStatus(`导出失败: ${err.message || err}`, true);
|
||
} finally {
|
||
buttons.forEach(b => { b.disabled = false; });
|
||
}
|
||
});
|
||
}
|