1728 lines
53 KiB
TypeScript
1728 lines
53 KiB
TypeScript
import JSZip from "jszip";
|
||
import {
|
||
DownloadIcon,
|
||
FileTextIcon,
|
||
LoaderIcon,
|
||
FileTypeIcon,
|
||
} from "lucide-react";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
type ComponentProps,
|
||
type HTMLAttributes,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import { Streamdown } from "streamdown";
|
||
import ExcelJS from "exceljs";
|
||
|
||
import {
|
||
Artifact,
|
||
ArtifactAction,
|
||
ArtifactActions,
|
||
ArtifactContent,
|
||
ArtifactHeader,
|
||
ArtifactTitle,
|
||
} from "@/components/ai-elements/artifact";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { DropdownSelector } from "@/components/ui/dropdown-selector";
|
||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import { streamdownPlugins } from "@/core/streamdown";
|
||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||
import { useMarkdownDownload } from "@/core/utils/markdown-download";
|
||
import { cn, copyToClipboard, truncateMiddle } from "@/lib/utils";
|
||
|
||
import { CitationLink } from "../citations/citation-link";
|
||
|
||
import { useArtifacts } from "./context";
|
||
|
||
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, "*");
|
||
}
|
||
}
|
||
|
||
export function ArtifactFileDetail({
|
||
className,
|
||
filepath: filepathFromProps,
|
||
threadId,
|
||
}: {
|
||
className?: string;
|
||
filepath: string;
|
||
threadId: string;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const { artifacts, setOpen, select, fullscreen, setFullscreen } =
|
||
useArtifacts();
|
||
|
||
const isWriteFile = useMemo(() => {
|
||
return filepathFromProps.startsWith("write-file:");
|
||
}, [filepathFromProps]);
|
||
const filepath = useMemo(() => {
|
||
if (isWriteFile) {
|
||
const url = new URL(filepathFromProps);
|
||
return decodeURIComponent(url.pathname);
|
||
}
|
||
return filepathFromProps;
|
||
}, [filepathFromProps, isWriteFile]);
|
||
// 获取文件名(不含路径)
|
||
const fileName = useMemo(() => getFileName(filepath), [filepath]);
|
||
const isSkillFile = useMemo(() => {
|
||
return filepath.endsWith(".skill");
|
||
}, [filepath]);
|
||
const { isCodeFile, language } = useMemo(() => {
|
||
if (isWriteFile) {
|
||
let language = checkCodeFile(filepath).language;
|
||
language ??= "text";
|
||
return { isCodeFile: true, language };
|
||
}
|
||
// Treat .skill files as markdown (they contain SKILL.md)
|
||
if (isSkillFile) {
|
||
return { isCodeFile: true, language: "markdown" };
|
||
}
|
||
return checkCodeFile(filepath);
|
||
}, [filepath, isWriteFile, isSkillFile]);
|
||
const previewable = useMemo(() => {
|
||
return language === "html" || language === "markdown";
|
||
}, [language]);
|
||
const artifactUrl = useMemo(() => {
|
||
if (!threadId) {
|
||
return "";
|
||
}
|
||
return urlOfArtifact({ filepath, threadId });
|
||
}, [filepath, threadId]);
|
||
const artifactPreviewKind = useMemo(() => {
|
||
return getArtifactPreviewKind(filepath);
|
||
}, [filepath]);
|
||
const artifactViewerSrcDoc = useMemo(() => {
|
||
if (!artifactUrl) {
|
||
return undefined;
|
||
}
|
||
return buildArtifactViewerSrcDoc({
|
||
artifactUrl,
|
||
fileName,
|
||
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,
|
||
enabled: Boolean(threadId) && isCodeFile && !isWriteFile,
|
||
});
|
||
|
||
const displayContent = content ?? "";
|
||
const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] =
|
||
useState(false);
|
||
|
||
const artifactOptions = useMemo(() => {
|
||
return (artifacts ?? []).map((artifactPath) => ({
|
||
value: artifactPath,
|
||
label: getFileName(artifactPath),
|
||
}));
|
||
}, [artifacts]);
|
||
|
||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||
const [zoom, setZoom] = useState(80);
|
||
|
||
// 是否可以转换为docx/pdf(仅markdown文件支持)
|
||
const canConvertToDocxPdf = language === "markdown";
|
||
|
||
// 使用 Markdown 下载 hook
|
||
const { isDownloading, downloadAsDocx, downloadAsPdf } = useMarkdownDownload({
|
||
onError: (error, format) => {
|
||
console.error(`Failed to download as ${format}:`, error);
|
||
toast.error(`Failed to download as ${format.toUpperCase()}`);
|
||
},
|
||
});
|
||
|
||
const resolveMarkdownAssetUrlForDownload = useCallback(
|
||
(rawPath: string): string | null => {
|
||
const normalizedRef = normalizeReference(rawPath);
|
||
if (!normalizedRef) return null;
|
||
if (isExternalReference(normalizedRef)) return normalizedRef;
|
||
|
||
if (normalizedRef.startsWith("/mnt/user-data/")) {
|
||
return urlOfArtifact({ filepath: normalizedRef, threadId });
|
||
}
|
||
if (normalizedRef.startsWith("mnt/user-data/")) {
|
||
return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId });
|
||
}
|
||
|
||
const resolvedVirtualPath = resolveReferencedVirtualPath(
|
||
normalizedRef,
|
||
filepath,
|
||
);
|
||
if (!resolvedVirtualPath) return null;
|
||
return urlOfArtifact({ filepath: resolvedVirtualPath, threadId });
|
||
},
|
||
[filepath, threadId],
|
||
);
|
||
|
||
// 下载为 DOCX
|
||
const handleDownloadDocx = useCallback(() => {
|
||
if (content) {
|
||
void downloadAsDocx(content, fileName, {
|
||
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
|
||
});
|
||
}
|
||
}, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]);
|
||
|
||
// 下载为 PDF
|
||
const handleDownloadPdf = useCallback(() => {
|
||
if (content) {
|
||
void downloadAsPdf(content, fileName, {
|
||
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
|
||
});
|
||
}
|
||
}, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]);
|
||
|
||
const handleDownloadMarkdownBundle = useCallback(async () => {
|
||
if (!threadId || !content) return;
|
||
setIsPackagingMarkdownBundle(true);
|
||
try {
|
||
const zip = new JSZip();
|
||
const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName;
|
||
const referencedTargets = collectMarkdownAssetTargets(content);
|
||
const refToVirtualPath = new Map<string, string>();
|
||
|
||
for (const ref of referencedTargets) {
|
||
const resolved = resolveReferencedVirtualPath(ref, filepath);
|
||
if (resolved) {
|
||
refToVirtualPath.set(ref, resolved);
|
||
}
|
||
}
|
||
|
||
const refToRelativeZipPath = new Map<string, string>();
|
||
const addedVirtualPaths = new Set<string>();
|
||
|
||
for (const [ref, virtualPath] of refToVirtualPath) {
|
||
const artifactEntryPath = toWorkspaceRelativePath(virtualPath);
|
||
if (!artifactEntryPath) continue;
|
||
|
||
const relativeFromMarkdown = toRelativePath(
|
||
dirnamePosix(markdownEntryPath),
|
||
artifactEntryPath,
|
||
);
|
||
refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath));
|
||
|
||
if (addedVirtualPaths.has(virtualPath)) continue;
|
||
addedVirtualPaths.add(virtualPath);
|
||
|
||
const response = await fetch(
|
||
urlOfArtifact({
|
||
filepath: virtualPath,
|
||
threadId,
|
||
}),
|
||
);
|
||
if (!response.ok) {
|
||
continue;
|
||
}
|
||
const data = await response.arrayBuffer();
|
||
zip.file(artifactEntryPath, data);
|
||
}
|
||
|
||
const rewrittenMarkdown = rewriteMarkdownLinksForBundle(
|
||
content,
|
||
refToRelativeZipPath,
|
||
);
|
||
zip.file(markdownEntryPath, rewrittenMarkdown);
|
||
|
||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||
const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`;
|
||
const url = URL.createObjectURL(zipBlob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = zipName;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
toast.success(t.common.exportSuccess);
|
||
} catch (error) {
|
||
console.error("Failed to package markdown bundle:", error);
|
||
toast.error("Failed to package markdown with referenced files.");
|
||
} finally {
|
||
setIsPackagingMarkdownBundle(false);
|
||
}
|
||
}, [threadId, content, filepath, fileName, t.common.exportSuccess]);
|
||
|
||
// 全屏切换处理
|
||
const handleFullscreenToggle = useCallback(() => {
|
||
const newFullscreen = !fullscreen;
|
||
setFullscreen(newFullscreen);
|
||
sendToParent({
|
||
type: POST_MESSAGE_TYPES.FULLSCREEN,
|
||
fullscreen: newFullscreen,
|
||
});
|
||
}, [fullscreen, setFullscreen]);
|
||
|
||
useEffect(() => {
|
||
if (previewable) {
|
||
setViewMode("preview");
|
||
} else {
|
||
setViewMode("code");
|
||
}
|
||
}, [previewable]);
|
||
|
||
return (
|
||
// 给滚动遮挡头部定位relative
|
||
<Artifact
|
||
className={cn(
|
||
"bg-background relative h-full overflow-hidden rounded-2xl",
|
||
className,
|
||
)}
|
||
>
|
||
<ArtifactHeader className="grid grid-cols-12 gap-3">
|
||
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
|
||
{previewable && (
|
||
<ToggleGroup
|
||
type="single"
|
||
variant={null}
|
||
size="default"
|
||
className="h-[28px] bg-white"
|
||
value={viewMode}
|
||
onValueChange={(value) => {
|
||
if (value) {
|
||
setViewMode(value as "code" | "preview");
|
||
}
|
||
}}
|
||
>
|
||
<ToggleGroupItem value="code">
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M5 6L2 9L5 12"
|
||
stroke="#150033"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M11 3L7 15"
|
||
stroke="#150033"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M13 6L16 9L13 12"
|
||
stroke="#150033"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</ToggleGroupItem>
|
||
<ToggleGroupItem value="preview">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="10"
|
||
viewBox="0 0 16 10"
|
||
fill="none"
|
||
>
|
||
<path
|
||
d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z"
|
||
stroke="#666666"
|
||
/>
|
||
<circle cx="8" cy="5" r="1.5" stroke="#666666" />
|
||
</svg>
|
||
</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
)}
|
||
{/* 仅在代码视图显示缩放控制 */}
|
||
{isCodeFile && viewMode === "code" && (
|
||
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
||
)}
|
||
</div>
|
||
<div className="col-span-6 flex min-w-0 items-center justify-center px-1">
|
||
<ArtifactTitle>
|
||
{isWriteFile ? (
|
||
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
|
||
{truncateMiddle(getFileName(filepath), 50)}
|
||
</div>
|
||
) : (
|
||
<DropdownSelector
|
||
value={filepath}
|
||
options={artifactOptions}
|
||
onChange={select}
|
||
/>
|
||
)}
|
||
</ArtifactTitle>
|
||
</div>
|
||
<div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden">
|
||
<ArtifactActions>
|
||
{isCodeFile && (
|
||
<ArtifactAction
|
||
label={t.clipboard.copyToClipboard}
|
||
disabled={!content}
|
||
onClick={async () => {
|
||
try {
|
||
await copyToClipboard(displayContent ?? "");
|
||
toast.success(t.clipboard.copiedToClipboard);
|
||
} catch (error) {
|
||
toast.error("Failed to copy to clipboard");
|
||
console.error(error);
|
||
}
|
||
}}
|
||
tooltip={t.clipboard.copyToClipboard}
|
||
>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M6 2H13C14.1046 2 15 2.89543 15 4V13"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<rect
|
||
x="2.5"
|
||
y="4.5"
|
||
width="10"
|
||
height="11"
|
||
rx="1.5"
|
||
stroke="#666666"
|
||
/>
|
||
</svg>
|
||
</ArtifactAction>
|
||
)}
|
||
{!isWriteFile && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<ArtifactAction
|
||
label={t.common.download}
|
||
tooltip={t.common.download}
|
||
>
|
||
{isDownloading ? (
|
||
<LoaderIcon className="size-4 animate-spin" />
|
||
) : (
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M16 9V14C16 15.1046 15.1046 16 14 16H4C2.89543 16 2 15.1046 2 14V9"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
/>
|
||
<path
|
||
d="M9 2V13M9 13L5 9M9 13L13 9"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</ArtifactAction>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||
{language === "markdown" ? (
|
||
<DropdownMenuItem
|
||
onClick={handleDownloadMarkdownBundle}
|
||
disabled={isPackagingMarkdownBundle}
|
||
className="cursor-pointer"
|
||
>
|
||
{isPackagingMarkdownBundle ? (
|
||
<LoaderIcon className="size-4 animate-spin" />
|
||
) : (
|
||
<DownloadIcon className="size-4" />
|
||
)}
|
||
{t.common.downloadOriginal}
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem asChild>
|
||
<a
|
||
href={urlOfArtifact({
|
||
filepath,
|
||
threadId: threadId ?? "",
|
||
download: true,
|
||
})}
|
||
target="_blank"
|
||
className="w-full cursor-pointer"
|
||
>
|
||
<DownloadIcon className="size-4" />
|
||
{t.common.downloadOriginal}
|
||
</a>
|
||
</DropdownMenuItem>
|
||
)}
|
||
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
|
||
{canConvertToDocxPdf && (
|
||
<>
|
||
<DropdownMenuItem
|
||
onClick={handleDownloadDocx}
|
||
disabled={isDownloading !== null || !content}
|
||
className="cursor-pointer"
|
||
>
|
||
<FileTextIcon className="size-4" />
|
||
{isDownloading === "docx"
|
||
? t.common.loading
|
||
: t.common.downloadAsDocx}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={handleDownloadPdf}
|
||
disabled={isDownloading !== null || !content}
|
||
className="cursor-pointer"
|
||
>
|
||
<FileTypeIcon className="size-4" />
|
||
{isDownloading === "pdf"
|
||
? t.common.loading
|
||
: t.common.downloadAsPdf}
|
||
</DropdownMenuItem>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
{/* 全屏按钮 */}
|
||
<ArtifactAction
|
||
label={
|
||
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
|
||
}
|
||
onClick={handleFullscreenToggle}
|
||
tooltip={
|
||
fullscreen ? t.common.closeFullScreen : t.common.fullScreen
|
||
}
|
||
>
|
||
{fullscreen ? (
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M6 2V4C6 5.10457 5.10457 6 4 6H2"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M6 16V14C6 12.8954 5.10457 12 4 12H2"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M12 2V4C12 5.10457 12.8954 6 14 6H16"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M12 16V14C12 12.8954 12.8954 12 14 12H16"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M5.75 15.5H4.5C3.39543 15.5 2.5 14.6046 2.5 13.5V12.25M2.5 5.75V4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H5.75M12.25 2.5H13.5C14.6046 2.5 15.5 3.39543 15.5 4.5V5.75M15.5 12.25V13.5C15.5 14.6046 14.6046 15.5 13.5 15.5H12.25"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</ArtifactAction>
|
||
{!fullscreen && (
|
||
<ArtifactAction
|
||
label={t.common.close}
|
||
onClick={() => setOpen(false)}
|
||
tooltip={t.common.close}
|
||
>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 18 18"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M4 14L14 4M4 4L14 14"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
</ArtifactAction>
|
||
)}
|
||
</ArtifactActions>
|
||
</div>
|
||
</ArtifactHeader>
|
||
<ArtifactContent>
|
||
{/* 遮挡多余的滚动顶部 */}
|
||
{/* <div className="absolute w-[calc(100%-40px)] bg-white z-20 h-5 rounded-t-[10px] top-[57px]"></div> */}
|
||
{previewable &&
|
||
viewMode === "preview" &&
|
||
(language === "markdown" || language === "html") && (
|
||
|
||
<ArtifactFilePreview
|
||
content={displayContent}
|
||
language={language ?? "text"}
|
||
zoom={zoom}
|
||
threadId={threadId}
|
||
filepath={filepath}
|
||
/>
|
||
|
||
)}
|
||
{isCodeFile && viewMode === "code" && (
|
||
<div className="min-h-full mb-[207px] rounded-b-[10px] bg-white p-0 mb-0">
|
||
<CodeEditor
|
||
className="size-full resize-none rounded-none border-none py-[20px]"
|
||
value={displayContent ?? ""}
|
||
zoom={zoom}
|
||
readonly
|
||
/>
|
||
</div>
|
||
)}
|
||
{!isCodeFile && (
|
||
artifactPreviewKind === "pdf" ? (
|
||
<ArtifactPdfPreview
|
||
className="h-full mb-[207px]"
|
||
artifactUrl={artifactUrl}
|
||
fileName={fileName}
|
||
/>
|
||
) : isOfficePreviewKind(artifactPreviewKind) ? (
|
||
<ArtifactOfficePreview
|
||
className="h-full mb-[207px]"
|
||
kind={artifactPreviewKind}
|
||
artifactUrl={artifactUrl}
|
||
fileName={fileName}
|
||
/>
|
||
) : (
|
||
<PreviewIframe
|
||
className="size-full border-0"
|
||
containerClassName="h-full mb-[207px]"
|
||
src={artifactViewerSrc}
|
||
srcDoc={artifactViewerSrcDoc}
|
||
sandbox={artifactViewerSandbox}
|
||
title={`Artifact preview: ${fileName}`}
|
||
/>
|
||
)
|
||
)}
|
||
</ArtifactContent>
|
||
</Artifact>
|
||
);
|
||
}
|
||
|
||
const USER_DATA_PREFIX = "/mnt/user-data/";
|
||
|
||
function normalizePosixPath(path: string): string {
|
||
const isAbs = path.startsWith("/");
|
||
const parts = path.split("/").filter((part) => part.length > 0);
|
||
const stack: string[] = [];
|
||
for (const part of parts) {
|
||
if (part === ".") continue;
|
||
if (part === "..") {
|
||
if (stack.length > 0) stack.pop();
|
||
continue;
|
||
}
|
||
stack.push(part);
|
||
}
|
||
return `${isAbs ? "/" : ""}${stack.join("/")}`;
|
||
}
|
||
|
||
function dirnamePosix(path: string): string {
|
||
const normalized = normalizePosixPath(path);
|
||
const index = normalized.lastIndexOf("/");
|
||
if (index <= 0) return "";
|
||
return normalized.slice(0, index);
|
||
}
|
||
|
||
function toWorkspaceRelativePath(virtualPath: string): string | null {
|
||
const normalized = normalizePosixPath(virtualPath);
|
||
if (!normalized.startsWith(USER_DATA_PREFIX)) return null;
|
||
return normalized.slice(USER_DATA_PREFIX.length) || "artifact";
|
||
}
|
||
|
||
function toRelativePath(fromDir: string, targetPath: string): string {
|
||
const from = normalizePosixPath(fromDir).split("/").filter(Boolean);
|
||
const to = normalizePosixPath(targetPath).split("/").filter(Boolean);
|
||
let i = 0;
|
||
while (i < from.length && i < to.length && from[i] === to[i]) {
|
||
i += 1;
|
||
}
|
||
const up = new Array(from.length - i).fill("..");
|
||
const down = to.slice(i);
|
||
return [...up, ...down].join("/") || ".";
|
||
}
|
||
|
||
function normalizeReference(ref: string): string {
|
||
const trimmed = ref.trim().replace(/^<|>$/g, "");
|
||
return trimmed.split(/[ \t]/)[0] ?? "";
|
||
}
|
||
|
||
function isExternalReference(ref: string): boolean {
|
||
return (
|
||
!ref ||
|
||
ref.startsWith("#") ||
|
||
ref.startsWith("//") ||
|
||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref)
|
||
);
|
||
}
|
||
|
||
function resolveReferencedVirtualPath(
|
||
ref: string,
|
||
baseFilepath: string,
|
||
): string | null {
|
||
const normalizedRef = normalizeReference(ref);
|
||
if (isExternalReference(normalizedRef)) return null;
|
||
|
||
let withoutHash = normalizedRef.split("#")[0] ?? normalizedRef;
|
||
withoutHash = withoutHash.split("?")[0] ?? withoutHash;
|
||
if (!withoutHash) return null;
|
||
|
||
if (withoutHash.startsWith("/mnt/user-data/")) {
|
||
return normalizePosixPath(withoutHash);
|
||
}
|
||
if (withoutHash.startsWith("mnt/user-data/")) {
|
||
return normalizePosixPath(`/${withoutHash}`);
|
||
}
|
||
if (withoutHash.startsWith("/")) {
|
||
return null;
|
||
}
|
||
|
||
const baseDir = dirnamePosix(baseFilepath);
|
||
const combined = normalizePosixPath(`${baseDir}/${withoutHash}`);
|
||
if (!combined.startsWith(USER_DATA_PREFIX)) return null;
|
||
return combined;
|
||
}
|
||
|
||
function collectMarkdownAssetTargets(markdown: string): Set<string> {
|
||
const targets = new Set<string>();
|
||
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
||
const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
|
||
|
||
for (const match of markdown.matchAll(markdownRefRegex)) {
|
||
const raw = match[1]?.trim();
|
||
if (raw) targets.add(raw);
|
||
}
|
||
for (const match of markdown.matchAll(htmlAttrRegex)) {
|
||
const raw = match[2]?.trim();
|
||
if (raw) targets.add(raw);
|
||
}
|
||
return targets;
|
||
}
|
||
|
||
function rewriteMarkdownLinksForBundle(
|
||
markdown: string,
|
||
refToRelativeZipPath: Map<string, string>,
|
||
): string {
|
||
const rewriteTarget = (rawTarget: string): string => {
|
||
const normalized = normalizeReference(rawTarget);
|
||
return refToRelativeZipPath.get(normalized) ?? rawTarget;
|
||
};
|
||
|
||
const markdownRewritten = markdown.replace(
|
||
/(!?\[[^\]]*\]\()([^)]+)(\))/g,
|
||
(_full, prefix, target, suffix) => {
|
||
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
|
||
},
|
||
);
|
||
|
||
return markdownRewritten.replace(
|
||
/(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*["'])([^"']+)(["'])/gi,
|
||
(_full, prefix, target, suffix) => {
|
||
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
|
||
},
|
||
);
|
||
}
|
||
|
||
export function ArtifactFilePreview({
|
||
content,
|
||
language,
|
||
zoom = 100,
|
||
threadId,
|
||
filepath,
|
||
}: {
|
||
content: string;
|
||
language: string;
|
||
zoom?: number;
|
||
threadId: string;
|
||
filepath?: string;
|
||
}) {
|
||
const zoomScale = zoom / 100;
|
||
const normalizedContent = useMemo(() => {
|
||
return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
|
||
}, [content, threadId, filepath]);
|
||
|
||
if (language === "markdown") {
|
||
return (
|
||
<div
|
||
className={cn("w-full bg-white mb-[207px] p-[20px]")}
|
||
style={{ "--zoom-scale": zoomScale } as CSSProperties}
|
||
>
|
||
<Streamdown
|
||
className="w-full"
|
||
{...streamdownPlugins}
|
||
components={{ a: CitationLink }}
|
||
>
|
||
{normalizedContent}
|
||
</Streamdown>
|
||
</div>
|
||
|
||
);
|
||
}
|
||
if (language === "html") {
|
||
return (
|
||
<PreviewIframe
|
||
className="size-full"
|
||
containerClassName="h-full mb-[207px]"
|
||
title="Artifact preview"
|
||
srcDoc={normalizedContent}
|
||
sandbox="allow-scripts allow-forms"
|
||
style={{ zoom: zoomScale }}
|
||
/>
|
||
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function PreviewIframe({
|
||
className,
|
||
containerClassName,
|
||
onLoad,
|
||
src,
|
||
srcDoc,
|
||
...props
|
||
}: ComponentProps<"iframe"> & {
|
||
containerClassName?: string;
|
||
}) {
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
setIsLoading(true);
|
||
}, [src, srcDoc]);
|
||
|
||
return (
|
||
<div className={cn("relative", containerClassName)}>
|
||
<iframe
|
||
className={className}
|
||
src={src}
|
||
srcDoc={srcDoc}
|
||
onLoad={(event) => {
|
||
setIsLoading(false);
|
||
onLoad?.(event);
|
||
}}
|
||
{...props}
|
||
/>
|
||
{isLoading && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
|
||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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,
|
||
artifactUrl,
|
||
fileName,
|
||
}: {
|
||
className?: string;
|
||
kind: ArtifactPreviewKind;
|
||
artifactUrl: string;
|
||
fileName: string;
|
||
}) {
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
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 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";
|
||
const canRenderPptx = kind === "pptx";
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
|
||
async function renderDocx() {
|
||
if (!canRenderDocx || !artifactUrl || !docxContainerRef.current) {
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
setError(null);
|
||
try {
|
||
const response = await fetch(artifactUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const blob = await response.blob();
|
||
const { renderAsync } = await import("docx-preview");
|
||
if (disposed || !docxContainerRef.current) {
|
||
return;
|
||
}
|
||
docxContainerRef.current.innerHTML = "";
|
||
await renderAsync(blob, docxContainerRef.current, undefined, {
|
||
ignoreWidth: false,
|
||
ignoreHeight: false,
|
||
breakPages: true,
|
||
inWrapper: true,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to render docx preview:", err);
|
||
if (!disposed) {
|
||
setError("无法预览该 DOCX 文件。");
|
||
}
|
||
} finally {
|
||
if (!disposed) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void renderDocx();
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [artifactUrl, canRenderDocx]);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
|
||
async function parseXlsx() {
|
||
if (!canRenderXlsx || !artifactUrl) {
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
setError(null);
|
||
setSheetNames([]);
|
||
setActiveSheet("");
|
||
setXlsxColumns([]);
|
||
setXlsxRows([]);
|
||
workbookRef.current = null;
|
||
try {
|
||
const response = await fetch(artifactUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
const bytes = await response.arrayBuffer();
|
||
const workbook = new ExcelJS.Workbook();
|
||
await workbook.xlsx.load(bytes);
|
||
workbookRef.current = workbook;
|
||
const names = workbook.worksheets.map((sheet) => sheet.name);
|
||
if (names.length === 0) {
|
||
throw new Error("Empty workbook");
|
||
}
|
||
if (disposed) return;
|
||
setSheetNames(names);
|
||
setActiveSheet(names[0] ?? "");
|
||
} catch (err) {
|
||
console.error("Failed to render xlsx preview:", err);
|
||
if (!disposed) {
|
||
setError("无法预览该 Excel 文件。");
|
||
}
|
||
} finally {
|
||
if (!disposed) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void parseXlsx();
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [artifactUrl, canRenderXlsx]);
|
||
|
||
useEffect(() => {
|
||
if (!canRenderXlsx || !activeSheet || !workbookRef.current) {
|
||
return;
|
||
}
|
||
try {
|
||
const sheet = workbookRef.current.getWorksheet(activeSheet);
|
||
if (!sheet) return;
|
||
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;
|
||
|
||
async function renderGrid() {
|
||
try {
|
||
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;
|
||
}
|
||
|
||
grid.columns = xlsxColumns;
|
||
grid.source = xlsxRows;
|
||
} catch (err) {
|
||
console.error("Failed to render RevoGrid preview:", err);
|
||
if (!disposed) {
|
||
setError("无法渲染 Excel 网格预览。");
|
||
}
|
||
}
|
||
}
|
||
|
||
void renderGrid();
|
||
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [canRenderXlsx, xlsxColumns, xlsxRows]);
|
||
useEffect(() => {
|
||
if (!canRenderPptx) {
|
||
return;
|
||
}
|
||
setIsLoading(false);
|
||
setError("请下载ppt文件以获得最佳效果");
|
||
}, [canRenderPptx]);
|
||
|
||
return (
|
||
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
|
||
{canRenderXlsx && sheetNames.length > 0 && (
|
||
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
|
||
{sheetNames.map((sheetName) => (
|
||
<button
|
||
key={sheetName}
|
||
type="button"
|
||
className={cn(
|
||
"rounded px-4 py-3 text-xs whitespace-nowrap",
|
||
activeSheet === sheetName
|
||
? "bg-[#1500331a] text-[#000000]"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
onClick={() => setActiveSheet(sheetName)}
|
||
>
|
||
{sheetName}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="h-full overflow-auto p-4">
|
||
{canRenderDocx && (
|
||
<div
|
||
ref={docxContainerRef}
|
||
className="docx-preview-wrap mx-auto max-w-5xl"
|
||
/>
|
||
)}
|
||
{canRenderXlsx && (
|
||
<div
|
||
ref={xlsxGridContainerRef}
|
||
className="h-full min-h-[320px] overflow-hidden rounded border"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<ArtifactPreviewFallback
|
||
fileName={fileName}
|
||
artifactUrl={artifactUrl}
|
||
message={error}
|
||
/>
|
||
)}
|
||
{isLoading && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
|
||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ArtifactPreviewFallback({
|
||
message,
|
||
fileName,
|
||
artifactUrl,
|
||
}: {
|
||
message: string;
|
||
fileName: string;
|
||
artifactUrl: string;
|
||
}) {
|
||
return (
|
||
<div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center">
|
||
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
|
||
<p className="text-muted-foreground mb-3 text-xs">{message}</p>
|
||
<a
|
||
className="text-primary text-sm font-medium underline"
|
||
href={artifactUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
点击下载
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function rewriteArtifactImagePaths(
|
||
content: string,
|
||
threadId?: string,
|
||
baseFilepath?: string,
|
||
) {
|
||
if (!threadId) {
|
||
return content;
|
||
}
|
||
|
||
const toArtifactUrl = (rawPath: string) => {
|
||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||
return resolveArtifactURL(normalizedPath, threadId);
|
||
};
|
||
const toArtifactUrlFromRelative = (rawPath: string) => {
|
||
const trimmed = rawPath.trim();
|
||
if (!baseFilepath || !trimmed) return null;
|
||
if (trimmed.startsWith("/") || trimmed.startsWith("//")) return null;
|
||
if (trimmed.startsWith("#")) return null;
|
||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return null;
|
||
|
||
const baseDir = baseFilepath.replace(/[^/]*$/, "");
|
||
if (!baseDir.startsWith("/")) return null;
|
||
|
||
const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname;
|
||
if (!absolutePath.startsWith("/mnt/user-data/")) return null;
|
||
return resolveArtifactURL(absolutePath, threadId);
|
||
};
|
||
|
||
const markdownRewritten = content.replace(
|
||
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
|
||
(_full, alt, rawPath) => {
|
||
return `})`;
|
||
},
|
||
);
|
||
const markdownRelativeRewritten = markdownRewritten.replace(
|
||
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
|
||
(_full, alt, rawPath) => {
|
||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||
if (!absoluteUrl) {
|
||
return ``;
|
||
}
|
||
return ``;
|
||
},
|
||
);
|
||
|
||
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
|
||
/!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/outputs\/[^)\s)]+)\s*[))]/g,
|
||
(_full, alt, rawPath) => {
|
||
return `})`;
|
||
},
|
||
);
|
||
|
||
return shorthandMarkdownRewritten.replace(
|
||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
|
||
(_full, prefix, quote, rawPath) => {
|
||
if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) {
|
||
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
|
||
}
|
||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||
if (absoluteUrl) {
|
||
return `${prefix}${quote}${absoluteUrl}${quote}`;
|
||
}
|
||
return `${prefix}${quote}${rawPath}${quote}`;
|
||
},
|
||
);
|
||
}
|
||
|
||
type ArtifactPreviewKind =
|
||
| "html"
|
||
| "image"
|
||
| "video"
|
||
| "audio"
|
||
| "pdf"
|
||
| "docx"
|
||
| "xlsx"
|
||
| "pptx"
|
||
| "other";
|
||
|
||
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
|
||
const lower = filepath.toLowerCase();
|
||
if (/\.(html?|xhtml)$/.test(lower)) return "html";
|
||
if (/\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)$/.test(lower))
|
||
return "image";
|
||
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video";
|
||
if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio";
|
||
if (lower.endsWith(".pdf")) return "pdf";
|
||
if (/\.(docx?)$/.test(lower)) return "docx";
|
||
if (/\.(xlsx?)$/.test(lower)) return "xlsx";
|
||
if (/\.(pptx?)$/.test(lower)) return "pptx";
|
||
return "other";
|
||
}
|
||
|
||
const OFFICE_PREVIEW_KINDS = new Set<ArtifactPreviewKind>([
|
||
"docx",
|
||
"xlsx",
|
||
"pptx",
|
||
]);
|
||
|
||
function isOfficePreviewKind(kind: ArtifactPreviewKind) {
|
||
return OFFICE_PREVIEW_KINDS.has(kind);
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function buildArtifactViewerSrcDoc({
|
||
artifactUrl,
|
||
fileName,
|
||
kind,
|
||
}: {
|
||
artifactUrl: string;
|
||
fileName: string;
|
||
kind: ArtifactPreviewKind;
|
||
}) {
|
||
const safeUrl = escapeHtml(artifactUrl);
|
||
const safeName = escapeHtml(fileName);
|
||
|
||
const content = (() => {
|
||
if (kind === "image") {
|
||
return `<img class="preview image" src="${safeUrl}" alt="${safeName}" />`;
|
||
}
|
||
if (kind === "video") {
|
||
return `<video class="preview media" src="${safeUrl}" controls playsinline preload="metadata"></video>`;
|
||
}
|
||
if (kind === "audio") {
|
||
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
|
||
}
|
||
if (kind === "pdf") {
|
||
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>`;
|
||
}
|
||
return `<div class="fallback">
|
||
<p class="title">${safeName}</p>
|
||
<p class="desc">This file type is not previewable in the custom viewer.</p>
|
||
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a>
|
||
</div>`;
|
||
})();
|
||
|
||
const bodyClass = kind === "image" ? "fullbleed" : "";
|
||
|
||
return `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<style>
|
||
:root {
|
||
--bg: #f8f9fb;
|
||
--panel: #ffffff;
|
||
--text: #0f172a;
|
||
--muted: #667085;
|
||
--line: #e4e7ec;
|
||
--radius: 12px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||
}
|
||
body {
|
||
display: flex;
|
||
align-items: stretch;
|
||
justify-content: stretch;
|
||
overflow: hidden;
|
||
padding: 12px;
|
||
}
|
||
body.fullbleed {
|
||
padding: 0;
|
||
}
|
||
.preview {
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--panel);
|
||
display: block;
|
||
}
|
||
.image {
|
||
object-fit: contain;
|
||
object-position: center;
|
||
background:
|
||
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 0 0/16px 16px,
|
||
linear-gradient(45deg, #f4f4f5 25%, transparent 25%, transparent 75%, #f4f4f5 75%, #f4f4f5) 8px 8px/16px 16px,
|
||
#fff;
|
||
}
|
||
.media {
|
||
object-fit: contain;
|
||
background: #000;
|
||
}
|
||
.frame {
|
||
border: 1px solid var(--line);
|
||
}
|
||
.audio-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--panel);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
}
|
||
.audio {
|
||
width: min(720px, 100%);
|
||
}
|
||
.fallback {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--panel);
|
||
display: grid;
|
||
place-content: center;
|
||
text-align: center;
|
||
gap: 10px;
|
||
padding: 24px;
|
||
}
|
||
.title {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
word-break: break-all;
|
||
}
|
||
.desc {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.link {
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
.link:hover { text-decoration: underline; }
|
||
</style>
|
||
</head>
|
||
<body class="${bodyClass}">
|
||
${content}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// 缩放比例选项
|
||
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
|
||
|
||
export type ArtifactZoomSelectorProps = Omit<
|
||
HTMLAttributes<HTMLDivElement>,
|
||
"onChange"
|
||
> & {
|
||
value?: number;
|
||
onChange?: (value: number) => void;
|
||
};
|
||
|
||
export const ArtifactZoomSelector = ({
|
||
value = 100,
|
||
onChange,
|
||
className,
|
||
...props
|
||
}: ArtifactZoomSelectorProps) => {
|
||
const handleZoomIn = () => {
|
||
const currentIndex = ZOOM_LEVELS.indexOf(value);
|
||
const nextValue = ZOOM_LEVELS[currentIndex + 1];
|
||
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) {
|
||
onChange?.(nextValue);
|
||
}
|
||
};
|
||
|
||
const handleZoomOut = () => {
|
||
const currentIndex = ZOOM_LEVELS.indexOf(value);
|
||
const prevValue = ZOOM_LEVELS[currentIndex - 1];
|
||
if (currentIndex > 0 && prevValue !== undefined) {
|
||
onChange?.(prevValue);
|
||
}
|
||
};
|
||
|
||
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
|
||
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"bg-background border-border inline-flex h-[28px] items-center gap-1 rounded-[10px] border backdrop-blur-sm",
|
||
"dark:border-border dark:bg-background",
|
||
className,
|
||
)}
|
||
{...props}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomIn}
|
||
disabled={!canZoomIn}
|
||
className={cn(
|
||
"flex h-full w-10 items-center justify-center rounded py-1 transition-colors",
|
||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
|
||
)}
|
||
aria-label="放大"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
>
|
||
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
|
||
<path
|
||
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
|
||
fill="#666666"
|
||
/>
|
||
<path
|
||
d="M5.33325 7.5H9.7777M7.55547 5V10"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<span
|
||
className={cn(
|
||
"text-foreground min-w-[36px] text-center text-xs font-medium",
|
||
"dark:text-foreground",
|
||
)}
|
||
>
|
||
{value}%
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleZoomOut}
|
||
disabled={!canZoomOut}
|
||
className={cn(
|
||
"flex h-full w-10 items-center justify-center rounded transition-colors",
|
||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||
"dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-foreground",
|
||
)}
|
||
aria-label="缩小"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
>
|
||
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666" />
|
||
<path
|
||
d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z"
|
||
fill="#666666"
|
||
/>
|
||
<path
|
||
d="M4.99927 7.5H9.99927"
|
||
stroke="#666666"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|