Files
gongxue-base/apps/server/src/ai-chat/ai-excel-reader.service.ts

236 lines
8.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
export interface ExcelSheetInfo {
name: string;
rowCount: number;
columns: string[];
}
export interface ExcelSheetRows {
name: string;
rows: string[][];
}
/**
* Structured Excel reader used by the AI chat. ExcelJS handles standard
* files; a direct OOXML fallback handles WPS-style files that prefix
* every element with a namespace. The agent reads sheets on demand
* (`list_excel_sheets` / `read_excel_rows`) instead of receiving one
* fixed text dump.
*/
@Injectable()
export class AiExcelReaderService {
async loadSheets(buffer: Buffer): Promise<ExcelSheetRows[]> {
try {
return await this.loadWithExcelJs(buffer);
} catch {
return this.loadWithFallback(buffer);
}
}
async extractText(buffer: Buffer): Promise<string> {
const sheets = await this.loadSheets(buffer);
return sheets
.map((sheet) => `# ${sheet.name}\n${sheet.rows.map((row) => row.join('\t')).join('\n')}`)
.join('\n');
}
/** Sheet list + row counts + a short sample, small enough for prompts. */
async overview(
buffer: Buffer,
sampleRows = 12,
): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {
const sheets = await this.loadSheets(buffer);
const info = sheets.map((sheet) => ({
name: sheet.name,
rowCount: sheet.rows.length,
columns: sheet.rows[0] ?? [],
}));
const lines: string[] = [];
for (const sheet of sheets) {
lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`);
for (const row of sheet.rows.slice(0, sampleRows)) {
lines.push(row.join('\t'));
}
if (sheet.rows.length > sampleRows) {
lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`);
}
}
return { sheets: info, text: lines.join('\n') };
}
async readRows(
buffer: Buffer,
sheetName: string | undefined,
startRow: number,
rowCount: number,
maxColumns: number,
): Promise<{
sheet: string;
rowCount: number;
startRow: number;
rows: string[][];
truncated: boolean;
}> {
const sheets = await this.loadSheets(buffer);
const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];
if (!sheet) {
return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false };
}
const from = Math.max(0, startRow - 1);
const limit = Math.min(rowCount, 200);
const slice = sheet.rows.slice(from, from + limit);
const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50)));
return {
sheet: sheet.name,
rowCount: sheet.rows.length,
startRow: from + 1,
rows,
truncated: slice.length < limit,
};
}
private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
const sheets: ExcelSheetRows[] = [];
workbook.eachSheet((sheet) => {
const rows: string[][] = [];
sheet.eachRow((row) => {
const values = Array.isArray(row.values) ? row.values.slice(1) : [];
rows.push(values.map((value) => this.stringifyCellValue(value)));
});
sheets.push({ name: sheet.name, rows });
});
return sheets;
}
private async loadWithFallback(buffer: Buffer): Promise<ExcelSheetRows[]> {
const zip = await JSZip.loadAsync(buffer);
const readEntry = async (name: string): Promise<string | null> => {
const entry = zip.file(name);
return entry ? entry.async('string') : null;
};
const workbookXml = await readEntry('xl/workbook.xml');
if (!workbookXml) throw new Error('workbook.xml missing');
const stripPrefixes = (value: string): string =>
value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? '');
const relTargets = new Map<string, string>();
for (const match of relsXml.matchAll(
/<Relationship[^>]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g,
)) {
const target = match[2].replace(/^\/+/, '');
relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`);
}
const sharedStrings = await this.parseSharedStringsFallback(readEntry);
const sheets: ExcelSheetRows[] = [];
const cleanWorkbook = stripPrefixes(workbookXml);
for (const match of cleanWorkbook.matchAll(/<sheet\b[^>]*\/?>/g)) {
const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');
const name = tag.match(/\bname="([^"]+)"/)?.[1];
const rid = tag.match(/\br:id="([^"]+)"/)?.[1];
if (!name || !rid) continue;
const target = relTargets.get(rid);
const sheetXml = target ? await readEntry(target) : null;
if (!sheetXml) continue;
sheets.push({
name: this.unescapeXml(name),
rows: this.sheetRowsFromXmlFallback(sheetXml, sharedStrings),
});
}
return sheets;
}
private async parseSharedStringsFallback(
readEntry: (name: string) => Promise<string | null>,
): Promise<string[]> {
const xml = await readEntry('xl/sharedStrings.xml');
if (!xml) return [];
const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
const strings: string[] = [];
for (const match of clean.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gs)) {
const texts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
this.unescapeXml(part[1]),
);
strings.push(texts.join(''));
}
return strings;
}
private sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] {
const rows: string[][] = [];
const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {
const cells = new Map<number, string>();
let maxColumn = -1;
for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {
const attrs = cellMatch[1];
const refMatch = attrs.match(/\br="([A-Z]+)\d+"/);
const column = refMatch ? this.columnIndex(refMatch[1]) : -1;
const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n';
const body = cellMatch[2] ?? '';
let value = '';
if (type === 's') {
const index = Number(body.match(/<v>([^<]*)<\/v>/)?.[1] ?? '');
value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
} else if (type === 'inlineStr') {
const texts = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) =>
this.unescapeXml(part[1]),
);
value = texts.join('');
} else {
value = this.unescapeXml(body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? '');
if (type === 'b') value = value === '1' ? 'true' : 'false';
}
if (column >= 0) {
cells.set(column, value);
maxColumn = Math.max(maxColumn, column);
}
}
if (maxColumn < 0) continue;
const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? '');
if (values.every((value) => value === '')) continue;
rows.push(values);
}
return rows;
}
private columnIndex(letters: string): number {
let index = 0;
for (const char of letters.toUpperCase()) {
index = index * 26 + (char.charCodeAt(0) - 64);
}
return index - 1;
}
private unescapeXml(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
String.fromCodePoint(Number.parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec)));
}
private stringifyCellValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value) || '';
} catch {
return '';
}
}
}