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)
|
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
||||||
|
|
||||||
if is_text_file_by_content(actual_path):
|
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)})
|
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.status_code == 200
|
||||||
assert response.text == "hello"
|
assert response.text == "hello"
|
||||||
assert response.headers.get("content-disposition", "").startswith("attachment;")
|
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-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
"@revolist/revogrid": "^4.21.3",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
|
"@tombcato/smart-ticker": "^1.2.4",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||||
|
|
@ -66,6 +68,7 @@
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
"hast": "^1.0.0",
|
"hast": "^1.0.0",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
|
|
@ -79,9 +82,8 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextra": "^4.6.1",
|
"nextra": "^4.6.1",
|
||||||
"nextra-theme-docs": "^4.6.1",
|
"nextra-theme-docs": "^4.6.1",
|
||||||
"nuxt-og-image": "^5.1.13",
|
|
||||||
"ogl": "^1.0.11",
|
"ogl": "^1.0.11",
|
||||||
"pptx-preview": "^1.0.7",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
|
|
@ -97,7 +99,6 @@
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { useSelectedSkillListener } from "@/hooks/use-selected-skill-listener";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
|
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() {
|
export default function ChatPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
const [sloganIndex, setSloganIndex] = useState(0);
|
||||||
const [settings, setSettings] = useLocalSettings();
|
const [settings, setSettings] = useLocalSettings();
|
||||||
const { setOpen: setSidebarOpen } = useSidebar();
|
const { setOpen: setSidebarOpen } = useSidebar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -92,6 +96,35 @@ export default function ChatPage() {
|
||||||
const initializedThreadRef = useRef<string | null>(null);
|
const initializedThreadRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isNewThread) {
|
if (!isNewThread) {
|
||||||
|
|
@ -329,10 +362,22 @@ export default function ChatPage() {
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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} */}
|
{/* threadTitle={title} */}
|
||||||
{title !== "Untitled" && (
|
{title !== "Untitled" && (
|
||||||
<ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
|
// <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
|
||||||
|
<Ticker
|
||||||
|
value={currentSlogan.text}
|
||||||
|
duration={800}
|
||||||
|
easing="easeInOut"
|
||||||
|
charWidth={1}
|
||||||
|
characterLists={tickerCharacterList}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
import * as XLSX from "xlsx";
|
import ExcelJS from "exceljs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Artifact,
|
Artifact,
|
||||||
|
|
@ -52,6 +52,98 @@ const POST_MESSAGE_TYPES = {
|
||||||
FULLSCREEN: "fullscreen",
|
FULLSCREEN: "fullscreen",
|
||||||
} as const;
|
} 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 {
|
function sendToParent(message: unknown): void {
|
||||||
if (window.parent !== window) {
|
if (window.parent !== window) {
|
||||||
window.parent.postMessage(message, "*");
|
window.parent.postMessage(message, "*");
|
||||||
|
|
@ -112,7 +204,7 @@ export function ArtifactFileDetail({
|
||||||
}, [filepath]);
|
}, [filepath]);
|
||||||
const artifactViewerSrcDoc = useMemo(() => {
|
const artifactViewerSrcDoc = useMemo(() => {
|
||||||
if (!artifactUrl) {
|
if (!artifactUrl) {
|
||||||
return "";
|
return undefined;
|
||||||
}
|
}
|
||||||
return buildArtifactViewerSrcDoc({
|
return buildArtifactViewerSrcDoc({
|
||||||
artifactUrl,
|
artifactUrl,
|
||||||
|
|
@ -120,6 +212,11 @@ export function ArtifactFileDetail({
|
||||||
kind: artifactPreviewKind,
|
kind: artifactPreviewKind,
|
||||||
});
|
});
|
||||||
}, [artifactUrl, fileName, 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({
|
const { content } = useArtifactContent({
|
||||||
threadId,
|
threadId,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
|
|
@ -608,7 +705,13 @@ export function ArtifactFileDetail({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCodeFile && (
|
{!isCodeFile && (
|
||||||
isOfficePreviewKind(artifactPreviewKind) ? (
|
artifactPreviewKind === "pdf" ? (
|
||||||
|
<ArtifactPdfPreview
|
||||||
|
className="h-full mb-[207px]"
|
||||||
|
artifactUrl={artifactUrl}
|
||||||
|
fileName={fileName}
|
||||||
|
/>
|
||||||
|
) : isOfficePreviewKind(artifactPreviewKind) ? (
|
||||||
<ArtifactOfficePreview
|
<ArtifactOfficePreview
|
||||||
className="h-full mb-[207px]"
|
className="h-full mb-[207px]"
|
||||||
kind={artifactPreviewKind}
|
kind={artifactPreviewKind}
|
||||||
|
|
@ -619,8 +722,9 @@ export function ArtifactFileDetail({
|
||||||
<PreviewIframe
|
<PreviewIframe
|
||||||
className="size-full border-0"
|
className="size-full border-0"
|
||||||
containerClassName="h-full mb-[207px]"
|
containerClassName="h-full mb-[207px]"
|
||||||
|
src={artifactViewerSrc}
|
||||||
srcDoc={artifactViewerSrcDoc}
|
srcDoc={artifactViewerSrcDoc}
|
||||||
sandbox="allow-same-origin allow-scripts allow-downloads"
|
sandbox={artifactViewerSandbox}
|
||||||
title={`Artifact preview: ${fileName}`}
|
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({
|
function ArtifactOfficePreview({
|
||||||
className,
|
className,
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -854,12 +1096,14 @@ function ArtifactOfficePreview({
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [xlsxHtml, setXlsxHtml] = useState<string>("");
|
|
||||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||||
const [activeSheet, setActiveSheet] = useState<string>("");
|
const [activeSheet, setActiveSheet] = useState<string>("");
|
||||||
|
const [xlsxColumns, setXlsxColumns] = useState<RevoGridColumn[]>([]);
|
||||||
|
const [xlsxRows, setXlsxRows] = useState<RevoGridRow[]>([]);
|
||||||
const docxContainerRef = useRef<HTMLDivElement | null>(null);
|
const docxContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pptxContainerRef = useRef<HTMLDivElement | null>(null);
|
const xlsxGridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const workbookRef = useRef<XLSX.WorkBook | null>(null);
|
const xlsxGridRef = useRef<RevoGridElement | null>(null);
|
||||||
|
const workbookRef = useRef<ExcelJS.Workbook | null>(null);
|
||||||
|
|
||||||
const canRenderDocx = kind === "docx";
|
const canRenderDocx = kind === "docx";
|
||||||
const canRenderXlsx = kind === "xlsx";
|
const canRenderXlsx = kind === "xlsx";
|
||||||
|
|
@ -918,6 +1162,10 @@ function ArtifactOfficePreview({
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSheetNames([]);
|
||||||
|
setActiveSheet("");
|
||||||
|
setXlsxColumns([]);
|
||||||
|
setXlsxRows([]);
|
||||||
workbookRef.current = null;
|
workbookRef.current = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(artifactUrl);
|
const response = await fetch(artifactUrl);
|
||||||
|
|
@ -925,21 +1173,16 @@ function ArtifactOfficePreview({
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
const bytes = await response.arrayBuffer();
|
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;
|
workbookRef.current = workbook;
|
||||||
const names = workbook.SheetNames ?? [];
|
const names = workbook.worksheets.map((sheet) => sheet.name);
|
||||||
if (names.length === 0) {
|
if (names.length === 0) {
|
||||||
throw new Error("Empty workbook");
|
throw new Error("Empty workbook");
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
setSheetNames(names);
|
setSheetNames(names);
|
||||||
const first = names[0] ?? "";
|
setActiveSheet(names[0] ?? "");
|
||||||
setActiveSheet(first);
|
|
||||||
const sheet = workbook.Sheets[first];
|
|
||||||
const html = sheet
|
|
||||||
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
|
|
||||||
: "";
|
|
||||||
setXlsxHtml(html);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to render xlsx preview:", err);
|
console.error("Failed to render xlsx preview:", err);
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
|
|
@ -963,63 +1206,65 @@ function ArtifactOfficePreview({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sheet = workbookRef.current.Sheets[activeSheet];
|
const sheet = workbookRef.current.getWorksheet(activeSheet);
|
||||||
if (!sheet) return;
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to switch xlsx sheet:", err);
|
console.error("Failed to switch xlsx sheet:", err);
|
||||||
setError("切换工作表失败。");
|
setError("切换工作表失败。");
|
||||||
}
|
}
|
||||||
}, [activeSheet, canRenderXlsx]);
|
}, [activeSheet, canRenderXlsx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!canRenderXlsx || !xlsxGridContainerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
type PptxPreviewModule = {
|
async function renderGrid() {
|
||||||
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);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(artifactUrl);
|
await ensureRevoGridDefined();
|
||||||
if (!response.ok) {
|
if (disposed || !xlsxGridContainerRef.current) return;
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
|
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;
|
grid.columns = xlsxColumns;
|
||||||
if (disposed || !pptxContainerRef.current) {
|
grid.source = xlsxRows;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const container = pptxContainerRef.current;
|
|
||||||
container.innerHTML = "";
|
|
||||||
const previewer = pptxModule.init(container, { width: 960, height: 540 });
|
|
||||||
await Promise.resolve(previewer.preview(bytes));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to render pptx preview:", err);
|
console.error("Failed to render RevoGrid preview:", err);
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
setError("无法预览该 PPT 文件。");
|
setError("无法渲染 Excel 网格预览。");
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!disposed) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void renderPptx();
|
void renderGrid();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [artifactUrl, canRenderPptx]);
|
}, [canRenderXlsx, xlsxColumns, xlsxRows]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canRenderPptx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
setError("请下载ppt文件以获得最佳效果");
|
||||||
|
}, [canRenderPptx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
|
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
|
||||||
|
|
@ -1030,10 +1275,10 @@ function ArtifactOfficePreview({
|
||||||
key={sheetName}
|
key={sheetName}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-2 py-1 text-xs whitespace-nowrap",
|
"rounded px-4 py-3 text-xs whitespace-nowrap",
|
||||||
activeSheet === sheetName
|
activeSheet === sheetName
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-[#1500331a] text-[#000000]"
|
||||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={() => setActiveSheet(sheetName)}
|
onClick={() => setActiveSheet(sheetName)}
|
||||||
>
|
>
|
||||||
|
|
@ -1050,16 +1295,10 @@ function ArtifactOfficePreview({
|
||||||
className="docx-preview-wrap mx-auto max-w-5xl"
|
className="docx-preview-wrap mx-auto max-w-5xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canRenderXlsx && xlsxHtml && (
|
{canRenderXlsx && (
|
||||||
<div
|
<div
|
||||||
className="artifact-xlsx-preview overflow-auto"
|
ref={xlsxGridContainerRef}
|
||||||
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
|
className="h-full min-h-[320px] overflow-hidden rounded border"
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canRenderPptx && (
|
|
||||||
<div
|
|
||||||
ref={pptxContainerRef}
|
|
||||||
className="pptx-preview-wrap mx-auto w-full overflow-auto"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1099,7 +1338,7 @@ function ArtifactPreviewFallback({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
在新标签页打开
|
点击下载
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1239,7 +1478,11 @@ function buildArtifactViewerSrcDoc({
|
||||||
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
|
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
|
||||||
}
|
}
|
||||||
if (kind === "pdf") {
|
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") {
|
if (kind === "html") {
|
||||||
return `<iframe class="preview frame" src="${safeUrl}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads"></iframe>`;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (kind === "pptx") {
|
if (kind === "pptx") {
|
||||||
await expect(page.locator(".pptx-preview-wrap").first()).toBeVisible({
|
await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue