Skip to content

Commit 57708ad

Browse files
authored
feat(cloud-agent): Show S3-backed image attachment thumbnails (#3707)
1 parent 789d73c commit 57708ad

10 files changed

Lines changed: 499 additions & 29 deletions

File tree

packages/api-client/src/posthog-client.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,50 @@ describe("PostHogAPIClient", () => {
451451
);
452452
});
453453

454+
it("presigns a task run artifact for preview", async () => {
455+
const fetch = vi.fn().mockResolvedValue({
456+
ok: true,
457+
json: async () => ({
458+
url: "https://s3.example.com/screenshot.png?signature=abc",
459+
expires_in: 3600,
460+
}),
461+
});
462+
const client = new PostHogAPIClient(
463+
"http://localhost:8000",
464+
async () => "token",
465+
async () => "token",
466+
123,
467+
);
468+
469+
(
470+
client as unknown as {
471+
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
472+
}
473+
).api = {
474+
baseUrl: "http://localhost:8000",
475+
fetcher: { fetch },
476+
};
477+
478+
await expect(
479+
client.presignTaskRunArtifact(
480+
"task-123",
481+
"run-123",
482+
"tasks/run-123/artifacts/screenshot.png",
483+
),
484+
).resolves.toBe("https://s3.example.com/screenshot.png?signature=abc");
485+
expect(fetch).toHaveBeenCalledWith(
486+
expect.objectContaining({
487+
method: "post",
488+
path: "/api/projects/123/tasks/task-123/runs/run-123/artifacts/presign/",
489+
overrides: {
490+
body: JSON.stringify({
491+
storage_path: "tasks/run-123/artifacts/screenshot.png",
492+
}),
493+
},
494+
}),
495+
);
496+
});
497+
454498
it("returns the redirect URL when authorizing an MCP installation", async () => {
455499
const fetch = vi.fn().mockResolvedValue({
456500
ok: true,

packages/api-client/src/posthog-client.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,6 +2900,34 @@ export class PostHogAPIClient {
29002900
return data.artifacts ?? [];
29012901
}
29022902

2903+
async presignTaskRunArtifact(
2904+
taskId: string,
2905+
runId: string,
2906+
storagePath: string,
2907+
): Promise<string> {
2908+
const teamId = await this.getTeamId();
2909+
const url = new URL(
2910+
`${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
2911+
);
2912+
const response = await this.api.fetcher.fetch({
2913+
method: "post",
2914+
url,
2915+
path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
2916+
overrides: {
2917+
body: JSON.stringify({ storage_path: storagePath }),
2918+
},
2919+
});
2920+
2921+
if (!response.ok) {
2922+
throw new Error(
2923+
`Failed to generate artifact preview URL: ${response.statusText}`,
2924+
);
2925+
}
2926+
2927+
const data = (await response.json()) as { url: string };
2928+
return data.url;
2929+
}
2930+
29032931
async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> {
29042932
const teamId = await this.getTeamId();
29052933
const url = new URL(

packages/core/src/sessions/promptContent.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ describe("promptContent", () => {
4949
});
5050

5151
it("extracts cloud resource_link attachments from file URIs", () => {
52-
const fileUri = "file:///tmp/workspace/attachments/Receipt-2264-0277.pdf";
52+
const fileUri =
53+
"file:///tmp/workspace/.posthog/attachments/run-123/artifact-456/Receipt-2264-0277.pdf";
5354

5455
const result = extractPromptDisplayContent([
5556
{ type: "text", text: "what is this about?" },
@@ -62,7 +63,23 @@ describe("promptContent", () => {
6263

6364
expect(result.text).toBe("what is this about?");
6465
expect(result.attachments).toEqual([
65-
{ id: fileUri, label: "Receipt-2264-0277.pdf" },
66+
{
67+
id: fileUri,
68+
label: "Receipt-2264-0277.pdf",
69+
cloudArtifact: { runId: "run-123", artifactId: "artifact-456" },
70+
},
71+
]);
72+
});
73+
74+
it("does not mark ordinary file URIs as cloud artifacts", () => {
75+
const fileUri = "file:///tmp/screenshot.png";
76+
77+
const result = extractPromptDisplayContent([
78+
{ type: "resource_link", uri: fileUri, name: "screenshot.png" },
79+
]);
80+
81+
expect(result.attachments).toEqual([
82+
{ id: fileUri, label: "screenshot.png" },
6683
]);
6784
});
6885
});

packages/core/src/sessions/promptContent.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ export function makeAttachmentUri(filePath: string): string {
2323
export interface AttachmentRef {
2424
id: string;
2525
label: string;
26+
cloudArtifact?: CloudArtifactRef;
27+
}
28+
29+
export interface CloudArtifactRef {
30+
runId: string;
31+
artifactId: string;
32+
}
33+
34+
function parseCloudArtifactRef(pathname: string): CloudArtifactRef | undefined {
35+
const segments = pathname.split("/").filter(Boolean);
36+
const posthogIndex = segments.lastIndexOf(".posthog");
37+
if (
38+
posthogIndex < 0 ||
39+
segments[posthogIndex + 1] !== "attachments" ||
40+
!segments[posthogIndex + 2] ||
41+
!segments[posthogIndex + 3]
42+
) {
43+
return undefined;
44+
}
45+
46+
return {
47+
runId: segments[posthogIndex + 2],
48+
artifactId: segments[posthogIndex + 3],
49+
};
2650
}
2751

2852
export function parseAttachmentUri(uri: string): AttachmentRef | null {
@@ -56,7 +80,8 @@ function parseFileUri(
5680
const pathname = decodeURIComponent(new URL(uri).pathname);
5781
const label =
5882
fallbackLabel?.trim() || getFileName(pathname) || "attachment";
59-
return { id: uri, label };
83+
const cloudArtifact = parseCloudArtifactRef(pathname);
84+
return { id: uri, label, ...(cloudArtifact ? { cloudArtifact } : {}) };
6085
} catch {
6186
const label = fallbackLabel?.trim() || getFileName(uri) || "attachment";
6287
return { id: uri, label };

packages/core/src/sessions/sessionService.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,6 +1595,11 @@ export class SessionService {
15951595
string,
15961596
Promise<CloudHydrationResult | undefined>
15971597
>();
1598+
/** Deduplicates concurrent manifest reads when a message renders many images. */
1599+
private cloudAttachmentManifestRequests = new Map<
1600+
string,
1601+
Promise<Array<{ id?: string; storage_path?: string }>>
1602+
>();
15981603
private idleKilledSubscription: { unsubscribe: () => void } | null = null;
15991604
/**
16001605
* Cached preview-config-options responses keyed by `${apiHost}::${adapter}`.
@@ -7088,6 +7093,69 @@ export class SessionService {
70887093
}
70897094
}
70907095

7096+
async getCloudAttachmentPreviewUrl(
7097+
taskId: string,
7098+
runId: string,
7099+
artifactId: string,
7100+
): Promise<string | null> {
7101+
const authStatus = await this.getAuthCredentialsStatus();
7102+
if (authStatus.kind !== "ready") return null;
7103+
7104+
try {
7105+
const artifacts = await this.getCloudAttachmentManifest(
7106+
authStatus.auth.client,
7107+
`${authStatus.auth.apiHost}:${authStatus.auth.projectId}`,
7108+
taskId,
7109+
runId,
7110+
);
7111+
const artifact = artifacts.find(
7112+
(candidate) => candidate.id === artifactId,
7113+
);
7114+
if (!artifact?.storage_path) return null;
7115+
7116+
return await authStatus.auth.client.presignTaskRunArtifact(
7117+
taskId,
7118+
runId,
7119+
artifact.storage_path,
7120+
);
7121+
} catch (error) {
7122+
this.d.log.warn("Failed to resolve cloud attachment preview", {
7123+
taskId,
7124+
runId,
7125+
artifactId,
7126+
error: String(error),
7127+
});
7128+
return null;
7129+
}
7130+
}
7131+
7132+
private getCloudAttachmentManifest(
7133+
client: AuthClient,
7134+
authIdentity: string,
7135+
taskId: string,
7136+
runId: string,
7137+
): Promise<Array<{ id?: string; storage_path?: string }>> {
7138+
const key = `${authIdentity}:${taskId}:${runId}`;
7139+
const existing = this.cloudAttachmentManifestRequests.get(key);
7140+
if (existing) return existing;
7141+
7142+
const request = client
7143+
.getTaskRun(taskId, runId)
7144+
.then(
7145+
(run: { artifacts?: Array<{ id?: string; storage_path?: string }> }) =>
7146+
run.artifacts ?? [],
7147+
);
7148+
this.cloudAttachmentManifestRequests.set(key, request);
7149+
7150+
const clear = () => {
7151+
if (this.cloudAttachmentManifestRequests.get(key) === request) {
7152+
this.cloudAttachmentManifestRequests.delete(key);
7153+
}
7154+
};
7155+
void request.then(clear, clear);
7156+
return request;
7157+
}
7158+
70917159
// --- Helper Methods ---
70927160

70937161
private async resolveCloudPrompt(
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { File } from "@phosphor-icons/react";
2+
import {
3+
SESSION_SERVICE,
4+
type SessionService,
5+
} from "@posthog/core/sessions/sessionService";
6+
import { useService } from "@posthog/di/react";
7+
import { isRasterImageFile, parseImageDataUrl } from "@posthog/shared";
8+
import {
9+
getAuthIdentity,
10+
useAuthStateValue,
11+
} from "@posthog/ui/features/auth/store";
12+
import { readFileAsDataUrl } from "@posthog/ui/features/message-editor/hostApi";
13+
import { MentionChip } from "@posthog/ui/features/sessions/components/session-update/parseFileMentions";
14+
import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes";
15+
import { useSessionTaskId } from "@posthog/ui/features/sessions/useSessionTaskId";
16+
import { SafeImagePreview } from "@posthog/ui/primitives/SafeImagePreview";
17+
import { Dialog, Text } from "@radix-ui/themes";
18+
import { useQuery } from "@tanstack/react-query";
19+
20+
function attachmentFilePath(id: string): string | null {
21+
if (id.startsWith("/") || /^[A-Za-z]:[\\/]/.test(id)) return id;
22+
if (!id.startsWith("file://")) return null;
23+
24+
try {
25+
return decodeURIComponent(new URL(id).pathname);
26+
} catch {
27+
return null;
28+
}
29+
}
30+
31+
function ImageAttachment({
32+
attachment,
33+
}: {
34+
attachment: UserMessageAttachment;
35+
}) {
36+
const filePath = attachmentFilePath(attachment.id);
37+
const taskId = useSessionTaskId();
38+
const authIdentity = useAuthStateValue(getAuthIdentity);
39+
const sessionService = useService<SessionService>(SESSION_SERVICE);
40+
const cloudArtifact = attachment.cloudArtifact;
41+
const { data: previewUrl } = useQuery({
42+
queryKey: cloudArtifact
43+
? [
44+
"cloudArtifactPreview",
45+
authIdentity,
46+
taskId,
47+
cloudArtifact.runId,
48+
cloudArtifact.artifactId,
49+
]
50+
: ["os", "readFileAsDataUrl", filePath],
51+
queryFn: () => {
52+
if (cloudArtifact && taskId) {
53+
return sessionService.getCloudAttachmentPreviewUrl(
54+
taskId,
55+
cloudArtifact.runId,
56+
cloudArtifact.artifactId,
57+
);
58+
}
59+
return readFileAsDataUrl({ filePath: filePath ?? "" });
60+
},
61+
enabled: cloudArtifact
62+
? taskId !== null && authIdentity !== null
63+
: filePath !== null,
64+
retry: false,
65+
staleTime: cloudArtifact ? 50 * 60 * 1000 : Infinity,
66+
});
67+
const parsedImage = previewUrl?.startsWith("data:")
68+
? parseImageDataUrl(previewUrl)
69+
: null;
70+
71+
if (!previewUrl) {
72+
return <MentionChip icon={<File size={12} />} label={attachment.label} />;
73+
}
74+
75+
return (
76+
<Dialog.Root>
77+
<Dialog.Trigger>
78+
<button
79+
type="button"
80+
className="group relative h-16 w-20 overflow-hidden rounded-md border border-gray-6 bg-gray-3"
81+
aria-label={`Preview ${attachment.label}`}
82+
>
83+
<img
84+
src={previewUrl}
85+
alt={attachment.label}
86+
className="size-full object-cover transition-transform group-hover:scale-105"
87+
/>
88+
<span className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-1.5 py-0.5 text-left text-[10px] text-white">
89+
{attachment.label}
90+
</span>
91+
</button>
92+
</Dialog.Trigger>
93+
<Dialog.Content maxWidth="85vw" className="w-fit p-[16px]">
94+
<Dialog.Title mb="2" className="text-sm">
95+
{attachment.label}
96+
</Dialog.Title>
97+
{parsedImage ? (
98+
<SafeImagePreview
99+
base64={parsedImage.base64}
100+
mimeType={parsedImage.mimeType}
101+
alt={attachment.label}
102+
className="max-h-[75vh] max-w-[80vw]"
103+
/>
104+
) : previewUrl.startsWith("data:") ? (
105+
<Text color="gray" className="text-sm">
106+
Unable to load image preview
107+
</Text>
108+
) : (
109+
<img
110+
src={previewUrl}
111+
alt={attachment.label}
112+
className="max-h-[75vh] max-w-[80vw] object-contain"
113+
/>
114+
)}
115+
</Dialog.Content>
116+
</Dialog.Root>
117+
);
118+
}
119+
120+
export function UserMessageAttachments({
121+
attachments,
122+
}: {
123+
attachments: UserMessageAttachment[];
124+
}) {
125+
return (
126+
<div className="flex flex-wrap items-center gap-1.5">
127+
{attachments.map((attachment) =>
128+
isRasterImageFile(attachment.label) ? (
129+
<ImageAttachment key={attachment.id} attachment={attachment} />
130+
) : (
131+
<MentionChip
132+
key={attachment.id}
133+
icon={<File size={12} />}
134+
label={attachment.label}
135+
/>
136+
),
137+
)}
138+
</div>
139+
);
140+
}

0 commit comments

Comments
 (0)