feat: 新增表格文件预览,优化知识图谱与 Agent 详情交互

- 新增 spreadsheet-preview 组件及 xlsx 解析工具,支持产物中的表格文件预览
- 优化知识图谱画布、详情面板及样式
- 调整 Agent 详情面板、聊天与消息列表交互

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 17:43:09 +08:00
1 parent 5daa4b3e20
commit 7d61bc09bc
18 files changed
+1092 -208

No files matched your search

+338
View File
@@ -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());
}