Skip to content
Merged
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
44 changes: 44 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,50 @@ describe("PostHogAPIClient", () => {
);
});

it("presigns a task run artifact for preview", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
url: "https://s3.example.com/screenshot.png?signature=abc",
expires_in: 3600,
}),
});
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = {
baseUrl: "http://localhost:8000",
fetcher: { fetch },
};

await expect(
client.presignTaskRunArtifact(
"task-123",
"run-123",
"tasks/run-123/artifacts/screenshot.png",
),
).resolves.toBe("https://s3.example.com/screenshot.png?signature=abc");
expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "post",
path: "/api/projects/123/tasks/task-123/runs/run-123/artifacts/presign/",
overrides: {
body: JSON.stringify({
storage_path: "tasks/run-123/artifacts/screenshot.png",
}),
},
}),
);
});

it("returns the redirect URL when authorizing an MCP installation", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
Expand Down
28 changes: 28 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2900,6 +2900,34 @@ export class PostHogAPIClient {
return data.artifacts ?? [];
}

async presignTaskRunArtifact(
taskId: string,
runId: string,
storagePath: string,
): Promise<string> {
const teamId = await this.getTeamId();
const url = new URL(
`${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
overrides: {
body: JSON.stringify({ storage_path: storagePath }),
},
});

if (!response.ok) {
throw new Error(
`Failed to generate artifact preview URL: ${response.statusText}`,
);
}

const data = (await response.json()) as { url: string };
return data.url;
}

async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> {
const teamId = await this.getTeamId();
const url = new URL(
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/sessions/promptContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ describe("promptContent", () => {
});

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

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

expect(result.text).toBe("what is this about?");
expect(result.attachments).toEqual([
{ id: fileUri, label: "Receipt-2264-0277.pdf" },
{
id: fileUri,
label: "Receipt-2264-0277.pdf",
cloudArtifact: { runId: "run-123", artifactId: "artifact-456" },
},
]);
});

it("does not mark ordinary file URIs as cloud artifacts", () => {
const fileUri = "file:///tmp/screenshot.png";

const result = extractPromptDisplayContent([
{ type: "resource_link", uri: fileUri, name: "screenshot.png" },
]);

expect(result.attachments).toEqual([
{ id: fileUri, label: "screenshot.png" },
]);
});
});
27 changes: 26 additions & 1 deletion packages/core/src/sessions/promptContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ export function makeAttachmentUri(filePath: string): string {
export interface AttachmentRef {
id: string;
label: string;
cloudArtifact?: CloudArtifactRef;
}

export interface CloudArtifactRef {
runId: string;
artifactId: string;
}

function parseCloudArtifactRef(pathname: string): CloudArtifactRef | undefined {
const segments = pathname.split("/").filter(Boolean);
const posthogIndex = segments.lastIndexOf(".posthog");
if (
posthogIndex < 0 ||
segments[posthogIndex + 1] !== "attachments" ||
!segments[posthogIndex + 2] ||
!segments[posthogIndex + 3]
) {
return undefined;
}

return {
runId: segments[posthogIndex + 2],
artifactId: segments[posthogIndex + 3],
};
}

export function parseAttachmentUri(uri: string): AttachmentRef | null {
Expand Down Expand Up @@ -56,7 +80,8 @@ function parseFileUri(
const pathname = decodeURIComponent(new URL(uri).pathname);
const label =
fallbackLabel?.trim() || getFileName(pathname) || "attachment";
return { id: uri, label };
const cloudArtifact = parseCloudArtifactRef(pathname);
return { id: uri, label, ...(cloudArtifact ? { cloudArtifact } : {}) };
} catch {
const label = fallbackLabel?.trim() || getFileName(uri) || "attachment";
return { id: uri, label };
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,11 @@ export class SessionService {
string,
Promise<CloudHydrationResult | undefined>
>();
/** Deduplicates concurrent manifest reads when a message renders many images. */
private cloudAttachmentManifestRequests = new Map<
string,
Promise<Array<{ id?: string; storage_path?: string }>>
>();
private idleKilledSubscription: { unsubscribe: () => void } | null = null;
/**
* Cached preview-config-options responses keyed by `${apiHost}::${adapter}`.
Expand Down Expand Up @@ -7088,6 +7093,69 @@ export class SessionService {
}
}

async getCloudAttachmentPreviewUrl(
taskId: string,
runId: string,
artifactId: string,
): Promise<string | null> {
const authStatus = await this.getAuthCredentialsStatus();
if (authStatus.kind !== "ready") return null;

try {
const artifacts = await this.getCloudAttachmentManifest(
authStatus.auth.client,
`${authStatus.auth.apiHost}:${authStatus.auth.projectId}`,
taskId,
runId,
);
const artifact = artifacts.find(
(candidate) => candidate.id === artifactId,
);
if (!artifact?.storage_path) return null;

return await authStatus.auth.client.presignTaskRunArtifact(
taskId,
runId,
artifact.storage_path,
);
} catch (error) {
this.d.log.warn("Failed to resolve cloud attachment preview", {
taskId,
runId,
artifactId,
error: String(error),
});
return null;
}
}

private getCloudAttachmentManifest(
client: AuthClient,
authIdentity: string,
taskId: string,
runId: string,
): Promise<Array<{ id?: string; storage_path?: string }>> {
const key = `${authIdentity}:${taskId}:${runId}`;
const existing = this.cloudAttachmentManifestRequests.get(key);
if (existing) return existing;

const request = client
.getTaskRun(taskId, runId)
.then(
(run: { artifacts?: Array<{ id?: string; storage_path?: string }> }) =>
run.artifacts ?? [],
);
this.cloudAttachmentManifestRequests.set(key, request);

const clear = () => {
if (this.cloudAttachmentManifestRequests.get(key) === request) {
this.cloudAttachmentManifestRequests.delete(key);
}
};
void request.then(clear, clear);
return request;
}

// --- Helper Methods ---

private async resolveCloudPrompt(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { File } from "@phosphor-icons/react";
import {
SESSION_SERVICE,
type SessionService,
} from "@posthog/core/sessions/sessionService";
import { useService } from "@posthog/di/react";
import { isRasterImageFile, parseImageDataUrl } from "@posthog/shared";
import {
getAuthIdentity,
useAuthStateValue,
} from "@posthog/ui/features/auth/store";
import { readFileAsDataUrl } from "@posthog/ui/features/message-editor/hostApi";
import { MentionChip } from "@posthog/ui/features/sessions/components/session-update/parseFileMentions";
import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes";
import { useSessionTaskId } from "@posthog/ui/features/sessions/useSessionTaskId";
import { SafeImagePreview } from "@posthog/ui/primitives/SafeImagePreview";
import { Dialog, Text } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";

function attachmentFilePath(id: string): string | null {
if (id.startsWith("/") || /^[A-Za-z]:[\\/]/.test(id)) return id;
if (!id.startsWith("file://")) return null;

try {
return decodeURIComponent(new URL(id).pathname);
} catch {
return null;
}
}

function ImageAttachment({
attachment,
}: {
attachment: UserMessageAttachment;
}) {
const filePath = attachmentFilePath(attachment.id);
const taskId = useSessionTaskId();
const authIdentity = useAuthStateValue(getAuthIdentity);
const sessionService = useService<SessionService>(SESSION_SERVICE);
const cloudArtifact = attachment.cloudArtifact;
const { data: previewUrl } = useQuery({
queryKey: cloudArtifact
? [
"cloudArtifactPreview",
authIdentity,
taskId,
cloudArtifact.runId,
cloudArtifact.artifactId,
Comment thread
tatoalo marked this conversation as resolved.
]
: ["os", "readFileAsDataUrl", filePath],
queryFn: () => {
if (cloudArtifact && taskId) {
return sessionService.getCloudAttachmentPreviewUrl(
taskId,
cloudArtifact.runId,
cloudArtifact.artifactId,
);
}
return readFileAsDataUrl({ filePath: filePath ?? "" });
},
enabled: cloudArtifact
? taskId !== null && authIdentity !== null
: filePath !== null,
retry: false,
staleTime: cloudArtifact ? 50 * 60 * 1000 : Infinity,
});
const parsedImage = previewUrl?.startsWith("data:")
? parseImageDataUrl(previewUrl)
: null;

if (!previewUrl) {
return <MentionChip icon={<File size={12} />} label={attachment.label} />;
}

return (
<Dialog.Root>
<Dialog.Trigger>
<button
type="button"
className="group relative h-16 w-20 overflow-hidden rounded-md border border-gray-6 bg-gray-3"
aria-label={`Preview ${attachment.label}`}
>
<img
src={previewUrl}
alt={attachment.label}
className="size-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-1.5 py-0.5 text-left text-[10px] text-white">
{attachment.label}
</span>
</button>
</Dialog.Trigger>
<Dialog.Content maxWidth="85vw" className="w-fit p-[16px]">
<Dialog.Title mb="2" className="text-sm">
{attachment.label}
</Dialog.Title>
{parsedImage ? (
<SafeImagePreview
base64={parsedImage.base64}
mimeType={parsedImage.mimeType}
alt={attachment.label}
className="max-h-[75vh] max-w-[80vw]"
/>
) : previewUrl.startsWith("data:") ? (
<Text color="gray" className="text-sm">
Unable to load image preview
</Text>
) : (
<img
src={previewUrl}
alt={attachment.label}
className="max-h-[75vh] max-w-[80vw] object-contain"
/>
)}
</Dialog.Content>
</Dialog.Root>
);
}

export function UserMessageAttachments({
attachments,
}: {
attachments: UserMessageAttachment[];
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
{attachments.map((attachment) =>
isRasterImageFile(attachment.label) ? (
<ImageAttachment key={attachment.id} attachment={attachment} />
) : (
<MentionChip
key={attachment.id}
icon={<File size={12} />}
label={attachment.label}
/>
),
)}
</div>
);
}
Loading
Loading