Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions apps/mobile/src/features/threads/HtmlRenderWebView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { useNavigation } from "@react-navigation/native";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
HTML_RENDER_COLUMN_WIDTH,
htmlRenderFileName,
htmlRenderFrameHeight,
htmlRenderThemeFragment,
Expand Down Expand Up @@ -180,23 +181,31 @@ export function HtmlRenderWebView(props: {
export function ThreadHtmlRender(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly render: HtmlRenderReference;
readonly frameWidth: number;
readonly iconColor: ColorValue;
readonly render: Pick<HtmlRenderReference, "title" | "height" | "heights"> &
(
| { readonly attachmentId: string }
| { readonly resource: Extract<AssetResource, { readonly _tag: "tool-input-html" }> }
);
readonly frameWidth?: number;
readonly iconColor?: ColorValue;
}) {
const navigation = useNavigation();
const { attachmentId, title } = props.render;
const height = htmlRenderFrameHeight(props.render, props.frameWidth);
const { title } = props.render;
const attachmentId = "attachmentId" in props.render ? props.render.attachmentId : null;
const height = htmlRenderFrameHeight(props.render, props.frameWidth ?? HTML_RENDER_COLUMN_WIDTH);
const fileName = htmlRenderFileName(title);
const resource = useMemo(
() => ({
_tag: "attachment" as const,
attachmentId,
fileName,
mimeType: "text/html",
disposition: "inline" as const,
}),
[attachmentId, fileName],
() =>
"resource" in props.render
? props.render.resource
: {
_tag: "attachment" as const,
attachmentId: props.render.attachmentId,
fileName,
mimeType: "text/html",
disposition: "inline" as const,
},
[props.render, fileName],
);
const asset = useAssetUrlState(props.environmentId, resource);
const refresh = useRefreshAssetUrl(props.environmentId, resource);
Expand Down Expand Up @@ -248,7 +257,7 @@ export function ThreadHtmlRender(props: {
<AppActivityIndicator />
</View>
)}
{uri !== null && !failed ? (
{uri !== null && !failed && attachmentId !== null ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Open ${title}`}
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
} from "../../lib/threadActivity";
import {
toolCallLines,
turnItemDetailRevision,
turnItemOutputImages,
turnItemOutputText,
} from "@t3tools/client-runtime/work-log/item-detail";
Expand All @@ -94,6 +95,7 @@ import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"
import type { FilePreviewSource } from "../../components/FilePreviewModal";
import { FilePreviewGallery } from "../../components/FilePreviewGallery";
import { ThreadMarkdownImage } from "./ThreadMarkdownImage";
import { ThreadHtmlRender } from "./HtmlRenderWebView";
import Animated, {
FadeIn,
FadeOut,
Expand Down Expand Up @@ -984,7 +986,12 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
expanded && !isRead && shownItem.type === "command_execution"
? toolCallLines({ command: shownItem.input })
: expanded && !isRead && shownItem.type === "dynamic_tool"
? toolCallLines({ args: shownItem.input })
? toolCallLines({
args: shownItem.input,
toolName: shownItem.toolName,
status: shownItem.status,
output: shownItem.output,
})
: expanded && shownItem.type === "file_search"
? toolCallLines({ args: { pattern: shownItem.pattern } })
: expanded && shownItem.type === "web_search"
Expand Down Expand Up @@ -1182,6 +1189,23 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
layout={WORK_LOG_LAYOUT_TRANSITION}
className={reasoning ? "ml-7 py-1" : "pb-1 pt-0.5"}
>
{call?.html ? (
<ThreadHtmlRender
key={`${props.environmentId}:${row.projectedItem.sourceThreadId}:${row.projectedItem.sourceItemId}:${turnItemDetailRevision(shownItem)}`}
environmentId={props.environmentId}
threadId={row.projectedItem.sourceThreadId}
render={{
title: "HTML preview",
height: 400,
resource: {
_tag: "tool-input-html",
threadId: row.projectedItem.sourceThreadId,
itemId: row.projectedItem.sourceItemId,
revision: turnItemDetailRevision(shownItem),
},
}}
/>
) : null}
{row.workEntry.questionAnswer ? (
<QuestionAnswerHistory
environmentId={props.environmentId}
Expand Down
88 changes: 85 additions & 3 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,37 @@ const oversizedScreenshotItem = {
},
};

const htmlItem = {
...screenshotItem,
id: TurnItemId.make("tool-html"),
toolName: "mcp__t3-code__html_preview",
input: {
html:
'<!doctype html><script src="https://cdn.example/chart.js"></script><main>' +
"x".repeat(300_000) +
"Complete document</main>",
},
};
const otherHtmlItem = {
...htmlItem,
id: TurnItemId.make("other-html"),
toolName: "other.html_preview",
};

const layerTest = Layer.mergeAll(
NodeHttpPlatform.layer,
Layer.mock(Orchestrator.OrchestratorV2)({
getTurnItem: ({ itemId }) =>
Effect.succeed(
itemId === screenshotItem.id
? screenshotItem
: itemId === oversizedScreenshotItem.id
? oversizedScreenshotItem
: null,
: itemId === htmlItem.id
? htmlItem
: itemId === otherHtmlItem.id
? otherHtmlItem
: itemId === oversizedScreenshotItem.id
? oversizedScreenshotItem
: null,
),
}),
layerConfig,
Expand All @@ -117,6 +138,67 @@ const layerTest = Layer.mergeAll(
).pipe(Layer.provideMerge(NodeServices.layer));

describe("AssetAccess", () => {
it.effect("inlines local images in tool HTML with the existing page preparation", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-tool-html-" });
const image = path.join(directory, "shot.png");
yield* fs.writeFile(image, screenshotPng);
const orchestrator = yield* Orchestrator.OrchestratorV2;
yield* Effect.gen(function* () {
const resource = {
_tag: "tool-input-html" as const,
threadId: htmlItem.threadId,
itemId: htmlItem.id,
revision: "local-image",
};
const result = yield* issueAssetUrl({ resource });
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separator = suffix.indexOf("/");
const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
expect(asset?.kind).toBe("bytes");
if (asset?.kind !== "bytes") return;
expect(new TextDecoder().decode(asset.bytes)).toContain(
`<img src="data:image/png;base64,${Buffer.from(screenshotPng).toString("base64")}">`,
);
}).pipe(
Effect.provideService(Orchestrator.OrchestratorV2, {
...orchestrator,
getTurnItem: () =>
Effect.succeed({ ...htmlItem, input: { html: `<img src="${image}">` } }),
}),
);
}).pipe(Effect.provide(layerTest)),
);

it.effect("serves the complete HTML tool input through a signed document URL", () =>
Effect.gen(function* () {
const resource = {
_tag: "tool-input-html" as const,
threadId: htmlItem.threadId,
itemId: htmlItem.id,
revision: "first",
};
const result = yield* issueAssetUrl({ resource });
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separator = suffix.indexOf("/");
const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1));
expect(asset?.kind).toBe("bytes");
if (asset?.kind !== "bytes") return;
expect(asset.mimeType).toBe("text/html");
const html = new TextDecoder().decode(asset.bytes);
expect(html).toContain('<script src="https://cdn.example/chart.js"></script>');
expect(html).toContain("Complete document</main>");
expect(html).toContain("t3-theme");
const revised = yield* issueAssetUrl({ resource: { ...resource, revision: "second" } });
expect(revised.relativeUrl).not.toBe(result.relativeUrl);
for (const itemId of [screenshotItem.id, otherHtmlItem.id, TurnItemId.make("missing")]) {
const error = yield* issueAssetUrl({ resource: { ...resource, itemId } }).pipe(Effect.flip);
expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError");
}
}).pipe(Effect.provide(layerTest)),
);
describe("readMcpAppHtml", () => {
it.effect("accepts snapshots at the stored HTML byte limit", () =>
Effect.gen(function* () {
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "@t3tools/shared/imageDimensions";
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
import { HTML_RENDER_MAX_SOURCE_LENGTH, isHtmlRenderTool } from "@t3tools/shared/htmlRender";
import {
isProjectFaviconPath,
PROJECT_FAVICON_FALLBACK_MARKER,
Expand All @@ -58,6 +59,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as HtmlRender from "../htmlRender/HtmlRender.ts";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
Expand Down Expand Up @@ -156,6 +158,14 @@ const AssetClaimsSchema = Schema.Union([
index: Schema.Number,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("tool-input-html"),
threadId: ThreadId,
itemId: TurnItemId,
revision: Schema.String,
expiresAt: Schema.Number,
}),
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("github-media"),
Expand Down Expand Up @@ -251,6 +261,24 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
: { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
});

const readToolInputHtml = Effect.fn("AssetAccess.readToolInputHtml")(function* (input: {
readonly threadId: ThreadId;
readonly itemId: TurnItemId;
}) {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const item = yield* orchestrator.getTurnItem(input);
if (item?.type !== "dynamic_tool" || !isHtmlRenderTool(item.toolName)) return null;
const args = item.input;
return typeof args === "object" &&
args !== null &&
"html" in args &&
typeof args.html === "string" &&
args.html.length <= HTML_RENDER_MAX_SOURCE_LENGTH &&
args.html.trim()
? args.html
: null;
});

/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
filePath: string,
Expand Down Expand Up @@ -747,6 +775,25 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}
break;
}
case "tool-input-html": {
const html = yield* readToolInputHtml(input.resource).pipe(
Effect.mapError(
(cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }),
),
);
if (html === null)
return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
claims = {
version: 1,
kind: "tool-input-html",
threadId: input.resource.threadId,
itemId: input.resource.itemId,
revision: input.resource.revision,
expiresAt,
};
fileName = "preview.html";
break;
}
case "tool-output-image": {
const image = yield* readToolOutputImage(input.resource).pipe(
Effect.mapError(
Expand Down Expand Up @@ -923,6 +970,35 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
return image ? ({ kind: "bytes", ...image } satisfies ResolvedAsset) : null;
}

if (claims.kind === "tool-input-html") {
const html = yield* readToolInputHtml(claims).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to read tool input HTML.", {
threadId: claims.threadId,
itemId: claims.itemId,
cause,
}),
),
Effect.orElseSucceed(() => null),
);
const document =
html === null
? null
: yield* HtmlRender.prepareDocument(html).pipe(
Effect.tapError((cause) =>
Effect.logWarning("Failed to prepare tool input HTML.", { cause }),
),
Effect.orElseSucceed(() => null),
);
return document === null
? null
: ({
kind: "bytes",
mimeType: "text/html",
bytes: new TextEncoder().encode(document.html),
} satisfies ResolvedAsset);
}

if (claims.kind === "native-app-icon") {
const nativeAppIconResolver = yield* NativeAppIconResolver.NativeAppIconResolver;
const iconPath = yield* nativeAppIconResolver.resolve(claims.app);
Expand Down
Loading
Loading