Compare commits

...

4 Commits

Author SHA1 Message Date
肖应宇 3a249b6f6d feat(artifacts): 使用 RevoGrid+ExcelJS 预览 Excel
- Excel 预览从 sheet_to_html 切换为 RevoGrid 网格渲染

- 使用 ExcelJS 解析工作簿并支持工作表切换

- 更新前端依赖:新增 @revolist/revogrid、exceljs;移除 nuxt-og-image、pptx-preview、xlsx
2026-04-11 17:33:22 +08:00
肖应宇 7ebd891258 feat:10分钟更换一次slogan 2026-04-11 17:05:44 +08:00
肖应宇 b55072b0eb feat(frontend): 接入 pdf.js 预览并调整产物预览逻辑 2026-04-11 16:23:00 +08:00
肖应宇 4bd36b4603 fix(backend): 修复二进制产物误判文本导致 PDF 返回异常 2026-04-11 16:22:57 +08:00
8 changed files with 1046 additions and 1791 deletions

View File

@ -176,6 +176,11 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
if is_text_file_by_content(actual_path):
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
try:
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
except UnicodeDecodeError:
# Some binary formats (e.g. certain PDFs) may not contain NUL bytes in
# the sampled chunk and be misclassified as text. Fall back to binary.
logger.debug("Artifact looked like text but is not valid UTF-8: %s", actual_path, exc_info=True)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})

View File

@ -102,3 +102,18 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
assert response.status_code == 200
assert response.text == "hello"
assert response.headers.get("content-disposition", "").startswith("attachment;")
def test_get_artifact_pdf_with_no_null_bytes_and_non_utf8_content_is_served_inline(tmp_path, monkeypatch) -> None:
artifact_path = tmp_path / "slides.pdf"
# No NUL bytes, but invalid UTF-8 to simulate binary content misdetected as text.
binary_content = b"%PDF-1.7\n\xff\xfe\xfa\n%%EOF"
artifact_path.write_bytes(binary_content)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path)
response = asyncio.run(artifacts_router.get_artifact("thread-1", "mnt/user-data/outputs/slides.pdf", _make_request()))
assert bytes(response.body) == binary_content
assert response.media_type == "application/pdf"
assert response.headers.get("content-disposition", "").startswith("inline;")

View File

@ -46,8 +46,10 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@revolist/revogrid": "^4.21.3",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@tombcato/smart-ticker": "^1.2.4",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",
@ -66,6 +68,7 @@
"docx-preview": "^0.3.7",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"gsap": "^3.13.0",
"hast": "^1.0.0",
"html2pdf.js": "^0.14.0",
@ -79,9 +82,8 @@
"next-themes": "^0.4.6",
"nextra": "^4.6.1",
"nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11",
"pptx-preview": "^1.0.7",
"pdfjs-dist": "^5.6.205",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",
@ -97,7 +99,6 @@
"unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
[
{
"text": "开工!摸鱼退散🐟💨",
"color": "#FF6B6B"
},
{
"text": "学习搞起,摆烂禁止🙅‍♂️",
"color": "#4ECDC4"
},
{
"text": "卷不动也得动💪",
"color": "#45B7D1"
},
{
"text": "搬砖学习,同步上线🧱",
"color": "#96CEB4"
},
{
"text": "别躺了,搞钱要紧💰",
"color": "#FFA559"
},
{
"text": "今日份努力已上线✨",
"color": "#A78BFA"
},
{
"text": "支棱起来,干活啦🚀",
"color": "#FF9F1C"
},
{
"text": "拒绝摆烂,从我做起😤",
"color": "#2EC4B6"
},
{
"text": "学习人,不犯困😪",
"color": "#E71D36"
},
{
"text": "冲冲冲,别摸鱼🐎",
"color": "#3A86FF"
}
]

View File

@ -40,10 +40,14 @@ import { env } from "@/env";
import { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
import { cn } from "@/lib/utils";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { Ticker } from "@tombcato/smart-ticker";
import "@tombcato/smart-ticker/style.css";
import motivationSlogans from "./motivation-slogans.json";
export default function ChatPage() {
const { t } = useI18n();
useSpecificChatMode();
const [sloganIndex, setSloganIndex] = useState(0);
const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const router = useRouter();
@ -92,6 +96,35 @@ export default function ChatPage() {
const initializedThreadRef = useRef<string | null>(null);
const { showNotification } = useNotification();
const currentSlogan =
motivationSlogans[sloganIndex % motivationSlogans.length] ?? {
text: "来,一起学习工作吧",
color: "#333333",
};
const tickerCharacterList = useMemo(() => {
const seen = new Set<string>();
const uniqueChars: string[] = [];
for (const slogan of motivationSlogans) {
for (const char of slogan.text) {
if (seen.has(char)) continue;
seen.add(char);
uniqueChars.push(char);
}
}
return uniqueChars.join("");
}, []);
useEffect(() => {
if (motivationSlogans.length <= 1) return;
const timer = window.setInterval(() => {
setSloganIndex((prev) => (prev + 1) % motivationSlogans.length);
}, 10 * 60 * 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!isNewThread) {
@ -329,10 +362,22 @@ export default function ChatPage() {
</svg>
</Button>
</div>
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
<div
className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]"
style={{
color: currentSlogan.color,
}}
>
{/* threadTitle={title} */}
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
<Ticker
value={currentSlogan.text}
duration={800}
easing="easeInOut"
charWidth={1}
characterLists={tickerCharacterList}
/>
)}
</div>
<div className="flex items-center justify-end gap-2 overflow-hidden">

View File

@ -17,7 +17,7 @@ import {
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
import {
Artifact,
@ -52,6 +52,98 @@ const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen",
} as const;
type RevoGridColumn = { prop: string; name: string };
type RevoGridRow = Record<string, string>;
type RevoGridSheetData = {
columns: RevoGridColumn[];
rows: RevoGridRow[];
};
type RevoGridElement = HTMLElement & {
columns: RevoGridColumn[];
source: RevoGridRow[];
readonly: boolean;
resize: boolean;
rowHeaders: boolean;
theme: string;
};
let revoGridLoaderPromise: Promise<void> | null = null;
function ensureRevoGridDefined() {
if (typeof window === "undefined") return Promise.resolve();
if (window.customElements.get("revo-grid")) return Promise.resolve();
if (!revoGridLoaderPromise) {
revoGridLoaderPromise = import("@revolist/revogrid/loader").then(
({ defineCustomElements }) => {
defineCustomElements(window);
},
);
}
return revoGridLoaderPromise;
}
function toExcelColumnLabel(index: number): string {
let n = index;
let label = "";
while (n > 0) {
const remainder = (n - 1) % 26;
label = String.fromCharCode(65 + remainder) + label;
n = Math.floor((n - 1) / 26);
}
return label || "A";
}
function toGridCellText(cell: ExcelJS.Cell): string {
if (cell.text) return cell.text;
const value = cell.value;
if (value == null) return "";
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") {
if ("result" in value && value.result != null) {
return String(value.result);
}
if ("text" in value && value.text) {
return String(value.text);
}
if ("hyperlink" in value && value.hyperlink) {
return String(value.hyperlink);
}
}
return String(value);
}
function toRevoGridSheetData(worksheet: ExcelJS.Worksheet): RevoGridSheetData {
const maxColumns = Math.max(worksheet.columnCount, 1);
const headerRow = worksheet.getRow(1);
const columns = Array.from({ length: maxColumns }, (_, idx) => {
const columnIndex = idx + 1;
const key = `c${columnIndex}`;
const header = toGridCellText(headerRow.getCell(columnIndex)).trim();
return {
prop: key,
name: header || toExcelColumnLabel(columnIndex),
};
});
const rows: RevoGridRow[] = [];
const lastRow = Math.max(worksheet.actualRowCount, worksheet.rowCount);
for (let rowIndex = 2; rowIndex <= lastRow; rowIndex += 1) {
const row = worksheet.getRow(rowIndex);
const rowData: RevoGridRow = {};
let hasContent = false;
for (let columnIndex = 1; columnIndex <= maxColumns; columnIndex += 1) {
const value = toGridCellText(row.getCell(columnIndex));
rowData[`c${columnIndex}`] = value;
if (!hasContent && value !== "") hasContent = true;
}
if (hasContent) rows.push(rowData);
}
return { columns, rows };
}
function sendToParent(message: unknown): void {
if (window.parent !== window) {
window.parent.postMessage(message, "*");
@ -112,7 +204,7 @@ export function ArtifactFileDetail({
}, [filepath]);
const artifactViewerSrcDoc = useMemo(() => {
if (!artifactUrl) {
return "";
return undefined;
}
return buildArtifactViewerSrcDoc({
artifactUrl,
@ -120,6 +212,11 @@ export function ArtifactFileDetail({
kind: artifactPreviewKind,
});
}, [artifactUrl, fileName, artifactPreviewKind]);
// Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js.
const artifactViewerSrc = useMemo(() => {
return undefined;
}, []);
const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads";
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
@ -608,7 +705,13 @@ export function ArtifactFileDetail({
</div>
)}
{!isCodeFile && (
isOfficePreviewKind(artifactPreviewKind) ? (
artifactPreviewKind === "pdf" ? (
<ArtifactPdfPreview
className="h-full mb-[207px]"
artifactUrl={artifactUrl}
fileName={fileName}
/>
) : isOfficePreviewKind(artifactPreviewKind) ? (
<ArtifactOfficePreview
className="h-full mb-[207px]"
kind={artifactPreviewKind}
@ -619,8 +722,9 @@ export function ArtifactFileDetail({
<PreviewIframe
className="size-full border-0"
containerClassName="h-full mb-[207px]"
src={artifactViewerSrc}
srcDoc={artifactViewerSrcDoc}
sandbox="allow-same-origin allow-scripts allow-downloads"
sandbox={artifactViewerSandbox}
title={`Artifact preview: ${fileName}`}
/>
)
@ -841,6 +945,144 @@ function PreviewIframe({
);
}
function ArtifactPdfPreview({
className,
artifactUrl,
fileName,
}: {
className?: string;
artifactUrl: string;
fileName: string;
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pageCount, setPageCount] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
async function renderPdf() {
if (!artifactUrl || !containerRef.current) return;
setIsLoading(true);
setError(null);
setPageCount(0);
containerRef.current.innerHTML = "";
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
if (disposed || !containerRef.current) {
await loadingTask.destroy();
return;
}
setPageCount(pdf.numPages);
const hostWidth = Math.max(
640,
Math.min(containerRef.current.clientWidth || 960, 1200),
);
const dpr = window.devicePixelRatio || 1;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
if (disposed || !containerRef.current) break;
const page = await pdf.getPage(pageNum);
const baseViewport = page.getViewport({ scale: 1 });
const scale = (hostWidth - 32) / baseViewport.width;
const viewport = page.getViewport({ scale });
const pageWrapper = document.createElement("div");
pageWrapper.className =
"mx-auto mb-4 w-fit rounded-md border border-[#e4e7ec] bg-white p-2 shadow-sm";
const canvas = document.createElement("canvas");
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
const context = canvas.getContext("2d");
if (!context) continue;
context.scale(dpr, dpr);
pageWrapper.appendChild(canvas);
containerRef.current.appendChild(pageWrapper);
const renderTask = page.render({
canvas,
canvasContext: context,
viewport,
});
await renderTask.promise;
}
} catch (err) {
console.error("Failed to render pdf preview:", err);
if (!disposed) {
setError("无法预览该 PDF 文件,请下载后查看。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderPdf();
return () => {
disposed = true;
};
}, [artifactUrl]);
if (error) {
return (
<div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}>
<div className="mx-auto grid max-w-xl gap-3 rounded-md border border-[#e4e7ec] bg-white p-5 text-center">
<p className="text-sm font-medium break-all">{fileName}</p>
<p className="text-muted-foreground text-sm">{error}</p>
<a
className="text-sm font-semibold text-blue-600 hover:underline"
href={artifactUrl}
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
</div>
);
}
return (
<div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}>
<div className="mb-3 text-center text-xs text-[#667085]">
{pageCount > 0 ? `${fileName} · ${pageCount} page(s)` : fileName}
</div>
<div ref={containerRef} />
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactOfficePreview({
className,
kind,
@ -854,12 +1096,14 @@ function ArtifactOfficePreview({
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [xlsxHtml, setXlsxHtml] = useState<string>("");
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>("");
const [xlsxColumns, setXlsxColumns] = useState<RevoGridColumn[]>([]);
const [xlsxRows, setXlsxRows] = useState<RevoGridRow[]>([]);
const docxContainerRef = useRef<HTMLDivElement | null>(null);
const pptxContainerRef = useRef<HTMLDivElement | null>(null);
const workbookRef = useRef<XLSX.WorkBook | null>(null);
const xlsxGridContainerRef = useRef<HTMLDivElement | null>(null);
const xlsxGridRef = useRef<RevoGridElement | null>(null);
const workbookRef = useRef<ExcelJS.Workbook | null>(null);
const canRenderDocx = kind === "docx";
const canRenderXlsx = kind === "xlsx";
@ -918,6 +1162,10 @@ function ArtifactOfficePreview({
}
setIsLoading(true);
setError(null);
setSheetNames([]);
setActiveSheet("");
setXlsxColumns([]);
setXlsxRows([]);
workbookRef.current = null;
try {
const response = await fetch(artifactUrl);
@ -925,21 +1173,16 @@ function ArtifactOfficePreview({
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const workbook = XLSX.read(bytes, { type: "array" });
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bytes);
workbookRef.current = workbook;
const names = workbook.SheetNames ?? [];
const names = workbook.worksheets.map((sheet) => sheet.name);
if (names.length === 0) {
throw new Error("Empty workbook");
}
if (disposed) return;
setSheetNames(names);
const first = names[0] ?? "";
setActiveSheet(first);
const sheet = workbook.Sheets[first];
const html = sheet
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
: "";
setXlsxHtml(html);
setActiveSheet(names[0] ?? "");
} catch (err) {
console.error("Failed to render xlsx preview:", err);
if (!disposed) {
@ -963,63 +1206,65 @@ function ArtifactOfficePreview({
return;
}
try {
const sheet = workbookRef.current.Sheets[activeSheet];
const sheet = workbookRef.current.getWorksheet(activeSheet);
if (!sheet) return;
setXlsxHtml(XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" }));
const { columns, rows } = toRevoGridSheetData(sheet);
setXlsxColumns(columns);
setXlsxRows(rows);
} catch (err) {
console.error("Failed to switch xlsx sheet:", err);
setError("切换工作表失败。");
}
}, [activeSheet, canRenderXlsx]);
useEffect(() => {
if (!canRenderXlsx || !xlsxGridContainerRef.current) {
return;
}
let disposed = false;
type PptxPreviewModule = {
init: (
container: HTMLElement,
options: { width: number; height: number },
) => {
preview: (buffer: ArrayBuffer) => Promise<void> | void;
};
};
async function renderPptx() {
if (!canRenderPptx || !artifactUrl || !pptxContainerRef.current) {
return;
}
setIsLoading(true);
setError(null);
async function renderGrid() {
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
await ensureRevoGridDefined();
if (disposed || !xlsxGridContainerRef.current) return;
let grid = xlsxGridRef.current;
if (!grid) {
grid = document.createElement("revo-grid") as RevoGridElement;
grid.style.width = "100%";
grid.style.height = "100%";
grid.readonly = true;
grid.resize = true;
grid.rowHeaders = true;
grid.theme = "default";
xlsxGridContainerRef.current.innerHTML = "";
xlsxGridContainerRef.current.appendChild(grid);
xlsxGridRef.current = grid;
}
const bytes = await response.arrayBuffer();
const pptxModule = (await import("pptx-preview")) as unknown as PptxPreviewModule;
if (disposed || !pptxContainerRef.current) {
return;
}
const container = pptxContainerRef.current;
container.innerHTML = "";
const previewer = pptxModule.init(container, { width: 960, height: 540 });
await Promise.resolve(previewer.preview(bytes));
grid.columns = xlsxColumns;
grid.source = xlsxRows;
} catch (err) {
console.error("Failed to render pptx preview:", err);
console.error("Failed to render RevoGrid preview:", err);
if (!disposed) {
setError("无法预览该 PPT 文件。");
}
} finally {
if (!disposed) {
setIsLoading(false);
setError("无法渲染 Excel 网格预览。");
}
}
}
void renderPptx();
void renderGrid();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderPptx]);
}, [canRenderXlsx, xlsxColumns, xlsxRows]);
useEffect(() => {
if (!canRenderPptx) {
return;
}
setIsLoading(false);
setError("请下载ppt文件以获得最佳效果");
}, [canRenderPptx]);
return (
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
@ -1030,10 +1275,10 @@ function ArtifactOfficePreview({
key={sheetName}
type="button"
className={cn(
"rounded px-2 py-1 text-xs whitespace-nowrap",
"rounded px-4 py-3 text-xs whitespace-nowrap",
activeSheet === sheetName
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
? "bg-[#1500331a] text-[#000000]"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveSheet(sheetName)}
>
@ -1050,16 +1295,10 @@ function ArtifactOfficePreview({
className="docx-preview-wrap mx-auto max-w-5xl"
/>
)}
{canRenderXlsx && xlsxHtml && (
{canRenderXlsx && (
<div
className="artifact-xlsx-preview overflow-auto"
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
/>
)}
{canRenderPptx && (
<div
ref={pptxContainerRef}
className="pptx-preview-wrap mx-auto w-full overflow-auto"
ref={xlsxGridContainerRef}
className="h-full min-h-[320px] overflow-hidden rounded border"
/>
)}
</div>
@ -1099,7 +1338,7 @@ function ArtifactPreviewFallback({
target="_blank"
rel="noreferrer"
>
</a>
</div>
);
@ -1239,7 +1478,11 @@ function buildArtifactViewerSrcDoc({
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
}
if (kind === "pdf") {
return `<iframe class="preview frame" src="${safeUrl}#view=FitH"></iframe>`;
return `<div class="fallback">
<p class="title">${safeName}</p>
<p class="desc">PDF preview is temporarily disabled. Please download the file to view it.</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a>
</div>`;
}
if (kind === "html") {
return `<iframe class="preview frame" src="${safeUrl}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads"></iframe>`;

View File

@ -165,7 +165,7 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
return;
}
if (kind === "pptx") {
await expect(page.locator(".pptx-preview-wrap").first()).toBeVisible({
await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({
timeout: 60_000,
});
return;