feat: 新增表格文件预览,优化知识图谱与 Agent 详情交互
- 新增 spreadsheet-preview 组件及 xlsx 解析工具,支持产物中的表格文件预览 - 优化知识图谱画布、详情面板及样式 - 调整 Agent 详情面板、聊天与消息列表交互 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5daa4b3e20
commit
7d61bc09bc
18 files changed
+1092
-208
No files matched your search
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Dependency-free .xlsx reader for in-browser previews.
|
||||
*
|
||||
* An .xlsx file is a ZIP archive of XML parts. We unzip it with the native
|
||||
* DecompressionStream("deflate-raw") and parse the parts with DOMParser, so
|
||||
* spreadsheet previews work in this offline deployment without adding a
|
||||
* SheetJS-style dependency. Covers the parts sandbox-generated workbooks
|
||||
* (openpyxl / pandas) actually use: shared strings, inline strings, numbers,
|
||||
* booleans and date-formatted cells. Legacy binary .xls is NOT a ZIP and is
|
||||
* not supported here.
|
||||
*/
|
||||
|
||||
export interface ParsedWorksheet {
|
||||
name: string;
|
||||
/** Dense row-major cell text. Ragged rows are allowed. */
|
||||
rows: string[][];
|
||||
/** True when rows were cut off at MAX_PREVIEW_ROWS. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_ROWS = 1000;
|
||||
const MAX_PREVIEW_COLS = 200;
|
||||
|
||||
export async function parseXlsx(data: ArrayBuffer): Promise<ParsedWorksheet[]> {
|
||||
const entries = readZipEntries(new Uint8Array(data));
|
||||
const readXml = async (path: string): Promise<Document | null> => {
|
||||
const entry = entries.get(path);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const bytes = await extractZipEntry(new Uint8Array(data), entry);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
return new DOMParser().parseFromString(text, "application/xml");
|
||||
};
|
||||
|
||||
const workbook = await readXml("xl/workbook.xml");
|
||||
if (!workbook) {
|
||||
throw new Error("不是有效的 xlsx 文件(缺少 xl/workbook.xml)");
|
||||
}
|
||||
const rels = await readXml("xl/_rels/workbook.xml.rels");
|
||||
const relTargets = new Map<string, string>();
|
||||
if (rels) {
|
||||
for (const rel of Array.from(elementsOf(rels, "Relationship"))) {
|
||||
const id = rel.getAttribute("Id");
|
||||
const target = rel.getAttribute("Target");
|
||||
if (id && target) {
|
||||
relTargets.set(id, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sharedStrings = await readSharedStrings(readXml);
|
||||
const dateStyleFlags = await readDateStyleFlags(readXml);
|
||||
|
||||
const sheets: ParsedWorksheet[] = [];
|
||||
for (const sheet of Array.from(elementsOf(workbook, "sheet"))) {
|
||||
const name = sheet.getAttribute("name") ?? `Sheet${sheets.length + 1}`;
|
||||
const rid =
|
||||
sheet.getAttribute("r:id") ??
|
||||
sheet.getAttributeNS(
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"id",
|
||||
) ??
|
||||
"";
|
||||
const target = relTargets.get(rid) ?? `worksheets/sheet${sheets.length + 1}.xml`;
|
||||
const path = target.startsWith("/")
|
||||
? target.slice(1)
|
||||
: `xl/${target.replace(/^\.\//, "")}`;
|
||||
const doc = await readXml(path);
|
||||
if (!doc) {
|
||||
continue;
|
||||
}
|
||||
sheets.push({
|
||||
name,
|
||||
...parseWorksheet(doc, sharedStrings, dateStyleFlags),
|
||||
});
|
||||
}
|
||||
if (sheets.length === 0) {
|
||||
throw new Error("xlsx 文件中没有可读取的工作表");
|
||||
}
|
||||
return sheets;
|
||||
}
|
||||
|
||||
/** Namespace-agnostic tag lookup — xlsx parts use a default namespace. */
|
||||
function elementsOf(root: Document | Element, localName: string): Element[] {
|
||||
return Array.from(root.getElementsByTagNameNS("*", localName));
|
||||
}
|
||||
|
||||
async function readSharedStrings(
|
||||
readXml: (path: string) => Promise<Document | null>,
|
||||
): Promise<string[]> {
|
||||
const doc = await readXml("xl/sharedStrings.xml");
|
||||
if (!doc) {
|
||||
return [];
|
||||
}
|
||||
return elementsOf(doc, "si").map((si) =>
|
||||
elementsOf(si, "t")
|
||||
.map((t) => t.textContent ?? "")
|
||||
.join(""),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For each cell style (cellXfs index), whether its number format is a
|
||||
* date/time format — those cells hold Excel serial date numbers that must be
|
||||
* rendered as dates instead of raw numbers like 45000.
|
||||
*/
|
||||
async function readDateStyleFlags(
|
||||
readXml: (path: string) => Promise<Document | null>,
|
||||
): Promise<boolean[]> {
|
||||
const doc = await readXml("xl/styles.xml");
|
||||
if (!doc) {
|
||||
return [];
|
||||
}
|
||||
const customDateFormats = new Set<number>();
|
||||
for (const numFmt of elementsOf(doc, "numFmt")) {
|
||||
const id = Number(numFmt.getAttribute("numFmtId"));
|
||||
const code = numFmt.getAttribute("formatCode") ?? "";
|
||||
if (Number.isFinite(id) && isDateFormatCode(code)) {
|
||||
customDateFormats.add(id);
|
||||
}
|
||||
}
|
||||
const cellXfs = elementsOf(doc, "cellXfs")[0];
|
||||
if (!cellXfs) {
|
||||
return [];
|
||||
}
|
||||
return elementsOf(cellXfs, "xf").map((xf) => {
|
||||
const numFmtId = Number(xf.getAttribute("numFmtId") ?? "0");
|
||||
return isBuiltinDateFormat(numFmtId) || customDateFormats.has(numFmtId);
|
||||
});
|
||||
}
|
||||
|
||||
function isBuiltinDateFormat(numFmtId: number): boolean {
|
||||
return (
|
||||
(numFmtId >= 14 && numFmtId <= 22) ||
|
||||
(numFmtId >= 27 && numFmtId <= 36) ||
|
||||
(numFmtId >= 45 && numFmtId <= 47) ||
|
||||
(numFmtId >= 50 && numFmtId <= 58)
|
||||
);
|
||||
}
|
||||
|
||||
function isDateFormatCode(code: string): boolean {
|
||||
// Strip quoted literals, color/condition tags and escapes, then look for
|
||||
// date/time tokens.
|
||||
const cleaned = code
|
||||
.replace(/"[^"]*"/g, "")
|
||||
.replace(/\[[^\]]*\]/g, "")
|
||||
.replace(/\\./g, "");
|
||||
return /[ymdhs]/i.test(cleaned) && !/#|0/.test(cleaned);
|
||||
}
|
||||
|
||||
function parseWorksheet(
|
||||
doc: Document,
|
||||
sharedStrings: string[],
|
||||
dateStyleFlags: boolean[],
|
||||
): { rows: string[][]; truncated: boolean } {
|
||||
const rows: string[][] = [];
|
||||
let truncated = false;
|
||||
for (const row of elementsOf(doc, "row")) {
|
||||
const rowNumber = Number(row.getAttribute("r") ?? rows.length + 1);
|
||||
if (rowNumber > MAX_PREVIEW_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
while (rows.length < rowNumber - 1) {
|
||||
rows.push([]);
|
||||
}
|
||||
const cells: string[] = [];
|
||||
for (const cell of elementsOf(row, "c")) {
|
||||
const ref = cell.getAttribute("r") ?? "";
|
||||
const columnIndex = ref
|
||||
? columnIndexOfRef(ref)
|
||||
: cells.length;
|
||||
if (columnIndex >= MAX_PREVIEW_COLS) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
while (cells.length < columnIndex) {
|
||||
cells.push("");
|
||||
}
|
||||
cells[columnIndex] = readCellText(cell, sharedStrings, dateStyleFlags);
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
// Drop trailing fully-empty rows so a sparse sheet doesn't render a long
|
||||
// empty tail.
|
||||
while (rows.length > 0 && rows[rows.length - 1]!.every((cell) => !cell)) {
|
||||
rows.pop();
|
||||
}
|
||||
return { rows, truncated };
|
||||
}
|
||||
|
||||
function readCellText(
|
||||
cell: Element,
|
||||
sharedStrings: string[],
|
||||
dateStyleFlags: boolean[],
|
||||
): string {
|
||||
const type = cell.getAttribute("t") ?? "n";
|
||||
if (type === "inlineStr") {
|
||||
const is = elementsOf(cell, "is")[0];
|
||||
if (!is) {
|
||||
return "";
|
||||
}
|
||||
return elementsOf(is, "t")
|
||||
.map((t) => t.textContent ?? "")
|
||||
.join("");
|
||||
}
|
||||
const value = elementsOf(cell, "v")[0]?.textContent ?? "";
|
||||
if (type === "s") {
|
||||
return sharedStrings[Number(value)] ?? "";
|
||||
}
|
||||
if (type === "b") {
|
||||
return value === "1" ? "TRUE" : "FALSE";
|
||||
}
|
||||
if (type === "str" || type === "e") {
|
||||
return value;
|
||||
}
|
||||
// Numeric cell — render date-styled serial numbers as dates.
|
||||
if (value !== "") {
|
||||
const styleIndex = Number(cell.getAttribute("s") ?? "-1");
|
||||
if (dateStyleFlags[styleIndex]) {
|
||||
const formatted = formatExcelSerialDate(Number(value));
|
||||
if (formatted) {
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatExcelSerialDate(serial: number): string | null {
|
||||
if (!Number.isFinite(serial)) {
|
||||
return null;
|
||||
}
|
||||
// Excel's day 0 is 1899-12-30 (the offset absorbs the fictitious 1900-02-29).
|
||||
const ms = Math.round(serial * 86400000);
|
||||
const date = new Date(Date.UTC(1899, 11, 30) + ms);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const datePart = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
||||
const timePart = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
|
||||
if (serial < 1) {
|
||||
return timePart;
|
||||
}
|
||||
return timePart === "00:00:00" ? datePart : `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
function columnIndexOfRef(ref: string): number {
|
||||
let index = 0;
|
||||
for (let i = 0; i < ref.length; i++) {
|
||||
const code = ref.charCodeAt(i);
|
||||
if (code >= 65 && code <= 90) {
|
||||
index = index * 26 + (code - 64);
|
||||
} else if (code >= 97 && code <= 122) {
|
||||
index = index * 26 + (code - 96);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.max(0, index - 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal ZIP reading (central directory + stored/deflate entries)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ZipEntry {
|
||||
compressionMethod: number;
|
||||
compressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
function readZipEntries(bytes: Uint8Array): Map<string, ZipEntry> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
// End of central directory record: signature 0x06054b50, located within the
|
||||
// last 22..(22+65535) bytes depending on the archive comment length.
|
||||
let eocd = -1;
|
||||
const scanStart = Math.max(0, bytes.length - 22 - 65535);
|
||||
for (let i = bytes.length - 22; i >= scanStart; i--) {
|
||||
if (view.getUint32(i, true) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) {
|
||||
throw new Error("不是有效的 xlsx/zip 文件");
|
||||
}
|
||||
const entryCount = view.getUint16(eocd + 10, true);
|
||||
let offset = view.getUint32(eocd + 16, true);
|
||||
|
||||
const entries = new Map<string, ZipEntry>();
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
if (view.getUint32(offset, true) !== 0x02014b50) {
|
||||
break;
|
||||
}
|
||||
const compressionMethod = view.getUint16(offset + 10, true);
|
||||
const compressedSize = view.getUint32(offset + 20, true);
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
const localHeaderOffset = view.getUint32(offset + 42, true);
|
||||
const name = new TextDecoder("utf-8").decode(
|
||||
bytes.subarray(offset + 46, offset + 46 + nameLength),
|
||||
);
|
||||
entries.set(name, { compressionMethod, compressedSize, localHeaderOffset });
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function extractZipEntry(
|
||||
bytes: Uint8Array,
|
||||
entry: ZipEntry,
|
||||
): Promise<Uint8Array> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const base = entry.localHeaderOffset;
|
||||
if (view.getUint32(base, true) !== 0x04034b50) {
|
||||
throw new Error("xlsx/zip 文件结构损坏");
|
||||
}
|
||||
const nameLength = view.getUint16(base + 26, true);
|
||||
const extraLength = view.getUint16(base + 28, true);
|
||||
const dataStart = base + 30 + nameLength + extraLength;
|
||||
const compressed = bytes.subarray(dataStart, dataStart + entry.compressedSize);
|
||||
if (entry.compressionMethod === 0) {
|
||||
return compressed;
|
||||
}
|
||||
if (entry.compressionMethod !== 8) {
|
||||
throw new Error(`不支持的 zip 压缩方式: ${entry.compressionMethod}`);
|
||||
}
|
||||
// .slice() copies into a fresh ArrayBuffer, which also satisfies BlobPart's
|
||||
// non-shared-buffer requirement.
|
||||
const stream = new Blob([compressed.slice()]).stream().pipeThrough(
|
||||
new DecompressionStream("deflate-raw"),
|
||||
);
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
Reference in new issue
Block a user