Files
tutti/packages/agent/gui/shared/AgentMessageMarkdownMedia.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

265 lines
7.7 KiB
TypeScript

import { useContext, useEffect, useState, type JSX } from "react";
import { ZoomableImage } from "../app/renderer/components/ZoomableImage";
import { cn } from "../app/renderer/lib/utils";
import { useTranslation } from "../i18n/index";
import {
getOptionalAgentHostApi,
useOptionalAgentHostApi
} from "../agentActivityHost";
import { workspaceFileName as basenameWorkspacePath } from "@tutti-os/workspace-file-manager/services";
import type { MarkdownDomProps } from "./AgentMessageMarkdown";
import { MarkdownLinkContext } from "./agentMessageMarkdownContext";
import {
cacheMarkdownMedia,
canRenderMarkdownVideoFallback,
peekCachedMarkdownMediaState,
releaseCachedMarkdownMedia,
resetCachedMarkdownMediaForTests,
resolveMarkdownMediaKind,
resolveMarkdownMediaType,
resolveMarkdownWorkspaceMediaPath,
resolveRenderableMarkdownMediaSrc,
retainCachedMarkdownMedia,
type MarkdownMediaState
} from "./agentMessageMarkdownLinks";
export function resetCachedMarkdownImagesForTests(): void {
resetCachedMarkdownMediaForTests();
}
export function MarkdownMedia({
node: _node,
src,
alt,
className,
title,
enableZoom = false,
...props
}: MarkdownDomProps<"img"> & {
enableZoom?: boolean;
}): JSX.Element {
"use memo";
const { t } = useTranslation();
const isInsideLink = useContext(MarkdownLinkContext);
const agentHostApi = useOptionalAgentHostApi() ?? getOptionalAgentHostApi();
const workspacePath =
typeof src === "string" ? resolveMarkdownWorkspaceMediaPath(src) : null;
const readWorkspaceImage = workspacePath
? agentHostApi?.workspace?.readFile
: undefined;
const canReadWorkspaceImage = Boolean(workspacePath && readWorkspaceImage);
const shouldEnableZoom = enableZoom && !isInsideLink;
const fallbackMediaKind =
typeof src === "string" ? resolveMarkdownMediaKind(src) : null;
const resolvedSrc =
typeof src === "string" ? resolveRenderableMarkdownMediaSrc(src) : src;
const [state, setState] = useState<MarkdownMediaState | null>(() =>
canReadWorkspaceImage && workspacePath
? (peekCachedMarkdownMediaState(workspacePath) ?? { status: "loading" })
: null
);
useEffect(() => {
if (!workspacePath || !readWorkspaceImage) {
setState(null);
return;
}
const resolvedWorkspacePath = workspacePath;
const resolvedReadWorkspaceImage = readWorkspaceImage;
const cached = retainCachedMarkdownMedia(resolvedWorkspacePath);
if (cached) {
setState({ kind: cached.kind, status: "ready", src: cached.src });
return () => {
releaseCachedMarkdownMedia(resolvedWorkspacePath, cached.src);
};
}
const mediaType = resolveMarkdownMediaType(resolvedWorkspacePath);
if (!mediaType) {
setState({
status: "error",
reason: "unsupported"
});
return;
}
const mediaKind = mediaType.kind;
const mediaMimeType = mediaType.mimeType;
let canceled = false;
let objectUrl: string | null = null;
setState({ status: "loading" });
async function loadWorkspaceMedia(): Promise<void> {
try {
const result = await resolvedReadWorkspaceImage({
path: resolvedWorkspacePath
});
if (canceled) {
return;
}
const bytes =
result.bytes instanceof Uint8Array
? result.bytes
: new Uint8Array(result.bytes);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
objectUrl = cacheMarkdownMedia(
resolvedWorkspacePath,
mediaKind,
new Blob([arrayBuffer], { type: mediaMimeType })
);
setState({ kind: mediaKind, status: "ready", src: objectUrl });
} catch (error) {
if (!canceled) {
setState({
status: "error",
reason: "read-failed",
detail: error instanceof Error ? error.message : String(error)
});
}
}
}
void loadWorkspaceMedia();
return () => {
canceled = true;
if (objectUrl) {
releaseCachedMarkdownMedia(resolvedWorkspacePath, objectUrl);
}
};
}, [canReadWorkspaceImage, workspacePath]);
if (!workspacePath || !readWorkspaceImage) {
if (fallbackMediaKind === "video") {
if (!canRenderMarkdownVideoFallback(src)) {
return <UnsupportedMarkdownMediaPreview />;
}
return (
<video
src={resolvedSrc}
aria-label={alt || undefined}
title={typeof title === "string" ? title : undefined}
controls
playsInline
preload="metadata"
className={cn(
"mt-2 block max-h-[360px] max-w-full rounded-[8px] bg-[var(--transparency-block)]",
className
)}
/>
);
}
if (!shouldEnableZoom) {
return (
<img
{...props}
src={resolvedSrc}
alt={alt}
title={title}
className={className}
/>
);
}
return (
<ZoomableImage
{...props}
src={resolvedSrc}
alt={alt}
title={title}
downloadName={resolveMarkdownImageDownloadName(src, alt)}
className={className}
wrapElement="span"
/>
);
}
if (state?.status === "ready") {
if (state.kind === "video") {
return (
<video
src={state.src}
aria-label={alt || undefined}
title={typeof title === "string" ? title : undefined}
controls
playsInline
preload="metadata"
className={cn(
"mt-2 block max-h-[360px] max-w-full rounded-[8px] bg-[var(--transparency-block)]",
className
)}
/>
);
}
if (!shouldEnableZoom) {
return (
<img
{...props}
src={state.src}
alt={alt}
title={title}
className={cn(
"mt-2 block max-h-[360px] max-w-full rounded-[8px] bg-[var(--transparency-block)] object-contain",
className
)}
/>
);
}
return (
<ZoomableImage
{...props}
src={state.src}
alt={alt}
title={title}
downloadName={resolveMarkdownImageDownloadName(workspacePath, alt)}
className={cn(
"mt-2 block max-h-[360px] max-w-full rounded-[8px] bg-[var(--transparency-block)] object-contain",
className
)}
wrapElement="span"
/>
);
}
return (
<span className="mt-2 flex min-h-[160px] w-full items-center justify-center rounded-[8px] bg-[var(--transparency-block)] px-5 py-5 text-center text-[13px] leading-5 text-[var(--text-tertiary)]">
{state?.status === "error"
? state.reason === "unsupported"
? t("agentHost.workspaceFileManager.previewUnsupported")
: t("agentHost.workspaceFileManager.previewReadFailed", {
message: state.detail ?? ""
})
: t("agentHost.workspaceFileManager.previewLoading")}
</span>
);
}
function UnsupportedMarkdownMediaPreview(): JSX.Element {
const { t } = useTranslation();
return (
<span className="mt-2 flex min-h-[160px] w-full items-center justify-center rounded-[8px] bg-[var(--transparency-block)] px-5 py-5 text-center text-[13px] leading-5 text-[var(--text-tertiary)]">
{t("agentHost.workspaceFileManager.previewUnsupported")}
</span>
);
}
function resolveMarkdownImageDownloadName(
src: unknown,
alt: unknown
): string | undefined {
if (typeof src === "string") {
const pathName = basenameWorkspacePath(src.trim());
if (pathName) {
return pathName;
}
}
return typeof alt === "string" ? alt.trim() || undefined : undefined;
}