Compare commits
4 Commits
5823fbfa72
...
3a249b6f6d
| Author | SHA1 | Date |
|---|---|---|
|
|
3a249b6f6d | |
|
|
7ebd891258 | |
|
|
b55072b0eb | |
|
|
4bd36b4603 |
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue