Agent/ui/export-report.ts
2026-08-06 22:17:39 +08:00

770 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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; });
}
});
}