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

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

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

No files matched your search

@@ -76,6 +76,7 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
@@ -137,6 +138,11 @@ export function ArtifactFileDetail({
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
// .xlsx is a ZIP archive — the generic binary iframe preview renders its
// raw bytes as mojibake, so it gets a dedicated table preview instead.
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
@@ -174,13 +180,14 @@ export function ArtifactFileDetail({
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isSpreadsheetFile) return "XLSX";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [filepath, isSkillFile, language]);
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -191,6 +198,9 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
@@ -310,6 +320,7 @@ export function ArtifactFileDetail({
useEffect(() => {
if (isCodeFile) {
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
@@ -325,6 +336,13 @@ export function ArtifactFileDetail({
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
);
if (cancelled || !res.ok) return;
if (isSpreadsheetFile) {
const buffer = await res.arrayBuffer();
if (!cancelled) {
setSpreadsheetData(buffer);
}
return;
}
const blob = await res.blob();
if (cancelled) return;
const u = URL.createObjectURL(blob);
@@ -342,12 +360,13 @@ export function ArtifactFileDetail({
return () => {
cancelled = true;
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) {
@@ -766,7 +785,18 @@ export function ArtifactFileDetail({
/>
</div>
)}
{!isCodeFile && isSpreadsheetFile && (
spreadsheetData ? (
<SpreadsheetPreview data={spreadsheetData} />
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
{t.common.loading}
</div>
)
)}
{!isCodeFile &&
!isSpreadsheetFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
) : (