128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
|
|
|
import { apiFetch } from "../api";
|
|
import type { AgentThreadState } from "../threads";
|
|
|
|
import { resolveStreamingWriteFileArgs } from "./parse-partial-json";
|
|
import { urlOfArtifact } from "./utils";
|
|
|
|
export async function loadArtifactContent({
|
|
filepath,
|
|
threadId,
|
|
isMock,
|
|
publicShareCode,
|
|
}: {
|
|
filepath: string;
|
|
threadId: string;
|
|
isMock?: boolean;
|
|
publicShareCode?: string;
|
|
}) {
|
|
let enhancedFilepath = filepath;
|
|
if (filepath.endsWith(".skill")) {
|
|
enhancedFilepath = filepath + "/SKILL.md";
|
|
}
|
|
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
|
const response = await apiFetch(url);
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
let detail = text.trim();
|
|
try {
|
|
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
|
|
const parsedDetail =
|
|
typeof parsed.detail === "string"
|
|
? parsed.detail
|
|
: typeof parsed.message === "string"
|
|
? parsed.message
|
|
: "";
|
|
if (parsedDetail) {
|
|
detail = parsedDetail;
|
|
}
|
|
} catch {
|
|
// Keep the raw response text as the diagnostic detail.
|
|
}
|
|
throw new Error(
|
|
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
|
|
);
|
|
}
|
|
return { content: text, url };
|
|
}
|
|
|
|
export function loadArtifactContentFromToolCall({
|
|
url: urlString,
|
|
thread,
|
|
}: {
|
|
url: string;
|
|
thread: BaseStream<AgentThreadState>;
|
|
}) {
|
|
const url = new URL(urlString);
|
|
const toolCallId = url.searchParams.get("tool_call_id");
|
|
const messageId = url.searchParams.get("message_id");
|
|
if (messageId && toolCallId) {
|
|
const message = thread.messages.find((message) => message.id === messageId);
|
|
if (message?.type === "ai") {
|
|
const toolCall = message.tool_calls?.find(
|
|
(candidate) => candidate.id === toolCallId,
|
|
);
|
|
if (toolCall) {
|
|
const { content } = resolveStreamingWriteFileArgs(message, toolCall);
|
|
if (content) return content;
|
|
}
|
|
const chunkOnly = resolveStreamingWriteFileArgs(message, {
|
|
id: toolCallId,
|
|
name: "write_file",
|
|
});
|
|
if (chunkOnly.content) return chunkOnly.content;
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeArtifactPath(path: string): string {
|
|
let normalized = path.trim();
|
|
const first = normalized[0];
|
|
const last = normalized.at(-1);
|
|
if (
|
|
normalized.length >= 2 &&
|
|
((first === '"' && last === '"') ||
|
|
(first === "'" && last === "'") ||
|
|
(first === "`" && last === "`"))
|
|
) {
|
|
normalized = normalized.slice(1, -1).trim();
|
|
}
|
|
return normalized.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
}
|
|
|
|
/**
|
|
* Last-resort preview for an output whose artifact HTTP request is unavailable.
|
|
*
|
|
* A completed `write_file` can be selected before its backing sandbox file is
|
|
* visible through the artifacts endpoint (or when a deployment temporarily
|
|
* serves a stale mount). The original tool arguments still contain the
|
|
* Markdown, so use the newest matching write as a read-only display fallback.
|
|
*/
|
|
export function loadArtifactContentFromLatestWriteFile({
|
|
filepath,
|
|
thread,
|
|
}: {
|
|
filepath: string;
|
|
thread: BaseStream<AgentThreadState>;
|
|
}): string | undefined {
|
|
const normalizedFilepath = normalizeArtifactPath(filepath);
|
|
if (!normalizedFilepath) return undefined;
|
|
|
|
for (const message of [...thread.messages].reverse()) {
|
|
if (message.type !== "ai") continue;
|
|
for (const toolCall of [...(message.tool_calls ?? [])].reverse()) {
|
|
if (toolCall.name !== "write_file") continue;
|
|
const args = resolveStreamingWriteFileArgs(message, toolCall);
|
|
if (
|
|
args.path &&
|
|
args.content &&
|
|
normalizeArtifactPath(args.path) === normalizedFilepath
|
|
) {
|
|
return args.content;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|