270 lines
6.9 KiB
TypeScript
270 lines
6.9 KiB
TypeScript
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
|
|
import path from "node:path"
|
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
|
|
export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput
|
|
|
|
export type ReplayPart =
|
|
| {
|
|
type: "text"
|
|
text: string
|
|
synthetic?: boolean
|
|
ignored?: boolean
|
|
}
|
|
| {
|
|
type: "file"
|
|
url: string
|
|
mime: string
|
|
filename?: string
|
|
}
|
|
| {
|
|
type: "reasoning"
|
|
text: string
|
|
}
|
|
|
|
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
|
|
return content.flatMap(contentBlockToParts)
|
|
}
|
|
|
|
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
|
switch (block.type) {
|
|
case "text":
|
|
return [
|
|
{
|
|
type: "text",
|
|
text: block.text,
|
|
...audienceFlags(block.annotations?.audience ?? undefined),
|
|
},
|
|
]
|
|
|
|
case "image":
|
|
if (block.data) {
|
|
return [
|
|
{
|
|
type: "file",
|
|
url: `data:${block.mimeType};base64,${block.data}`,
|
|
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
|
mime: block.mimeType,
|
|
},
|
|
]
|
|
}
|
|
if (block.uri?.startsWith("data:")) {
|
|
return [
|
|
{
|
|
type: "file",
|
|
url: block.uri,
|
|
filename: filenameFromUri(block.uri) ?? "image",
|
|
mime: block.mimeType,
|
|
},
|
|
]
|
|
}
|
|
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
|
return [
|
|
{
|
|
type: "file",
|
|
url: block.uri,
|
|
filename: filenameFromUri(block.uri) ?? "image",
|
|
mime: block.mimeType,
|
|
},
|
|
]
|
|
}
|
|
return []
|
|
|
|
case "resource_link":
|
|
return [resourceLinkToPart(block)]
|
|
|
|
case "resource":
|
|
if ("text" in block.resource) {
|
|
try {
|
|
const parsed = new URL(block.resource.uri)
|
|
if (parsed.protocol === "file:") {
|
|
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
|
|
let filepath: string
|
|
try {
|
|
filepath = fileURLToPath(parsed)
|
|
} catch {
|
|
filepath = decodeURIComponent(parsed.pathname)
|
|
}
|
|
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
|
|
return [
|
|
{
|
|
type: "text",
|
|
text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`,
|
|
},
|
|
]
|
|
}
|
|
} catch {}
|
|
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
|
|
}
|
|
if (block.resource.mimeType) {
|
|
return [
|
|
{
|
|
type: "file",
|
|
url: block.resource.uri.startsWith("data:")
|
|
? block.resource.uri
|
|
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
|
|
filename: filenameFromUri(block.resource.uri) ?? "file",
|
|
mime: block.resource.mimeType,
|
|
},
|
|
]
|
|
}
|
|
return []
|
|
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
|
|
return parts.flatMap(partToContentChunks)
|
|
}
|
|
|
|
export function partToContentChunks(part: ReplayPart): ContentChunk[] {
|
|
switch (part.type) {
|
|
case "text":
|
|
if (!part.text) return []
|
|
return [
|
|
{
|
|
content: {
|
|
type: "text",
|
|
text: part.text,
|
|
...partAudience(part),
|
|
},
|
|
},
|
|
]
|
|
|
|
case "file":
|
|
return filePartToContentChunks(part)
|
|
|
|
case "reasoning":
|
|
if (!part.text) return []
|
|
return [
|
|
{
|
|
content: {
|
|
type: "text",
|
|
text: part.text,
|
|
},
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
function resourceLinkToPart(link: ResourceLink): PromptPart {
|
|
const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name)
|
|
if (parsed.type === "file") return parsed
|
|
return { type: "text", text: parsed.text }
|
|
}
|
|
|
|
function uriToFilePart(
|
|
uri: string,
|
|
mime: string,
|
|
filename?: string,
|
|
): SessionV1.FilePartInput | SessionV1.TextPartInput {
|
|
try {
|
|
if (uri.startsWith("file://")) {
|
|
return {
|
|
type: "file",
|
|
url: uri,
|
|
filename: filename ?? filenameFromUri(uri) ?? "file",
|
|
mime,
|
|
}
|
|
}
|
|
if (uri.startsWith("zed://")) {
|
|
const pathname = new URL(uri).searchParams.get("path")
|
|
if (pathname) {
|
|
return {
|
|
type: "file",
|
|
url: pathToFileURL(pathname).href,
|
|
filename: filename ?? (path.basename(pathname) || "file"),
|
|
mime,
|
|
}
|
|
}
|
|
}
|
|
return { type: "text", text: uri }
|
|
} catch {
|
|
return { type: "text", text: uri }
|
|
}
|
|
}
|
|
|
|
function filePartToContentChunks(part: Extract<ReplayPart, { type: "file" }>): ContentChunk[] {
|
|
if (part.url.startsWith("file://")) {
|
|
return [
|
|
{
|
|
content: {
|
|
type: "resource_link",
|
|
uri: part.url,
|
|
name: part.filename ?? "file",
|
|
mimeType: part.mime,
|
|
},
|
|
},
|
|
]
|
|
}
|
|
if (!part.url.startsWith("data:")) return []
|
|
|
|
const data = decodeDataUrl(part.url)
|
|
if (!data) return []
|
|
if (data.mime.startsWith("image/")) {
|
|
return [
|
|
{
|
|
content: {
|
|
type: "image",
|
|
mimeType: data.mime,
|
|
data: data.base64,
|
|
uri: pathToFileURL(part.filename ?? "image").href,
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
return [
|
|
{
|
|
content: {
|
|
type: "resource",
|
|
resource:
|
|
data.mime.startsWith("text/") || data.mime === "application/json"
|
|
? {
|
|
uri: pathToFileURL(part.filename ?? "file").href,
|
|
mimeType: data.mime,
|
|
text: Buffer.from(data.base64, "base64").toString("utf8"),
|
|
}
|
|
: {
|
|
uri: pathToFileURL(part.filename ?? "file").href,
|
|
mimeType: data.mime,
|
|
blob: data.base64,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
function decodeDataUrl(url: string) {
|
|
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
|
|
if (!match) return
|
|
return { mime: match[1], base64: match[2] }
|
|
}
|
|
|
|
function audienceFlags(audience: readonly Role[] | null | undefined) {
|
|
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true }
|
|
if (audience?.length === 1 && audience[0] === "user") return { ignored: true }
|
|
return {}
|
|
}
|
|
|
|
function partAudience(part: Extract<ReplayPart, { type: "text" }>) {
|
|
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
|
if (!audience) return {}
|
|
return { annotations: { audience } }
|
|
}
|
|
|
|
function filenameFromUri(uri: string | undefined) {
|
|
if (!uri) return
|
|
if (uri.startsWith("data:")) return
|
|
try {
|
|
const parsed = new URL(uri)
|
|
const name = path.basename(parsed.pathname)
|
|
return name || undefined
|
|
} catch {
|
|
return path.basename(uri) || undefined
|
|
}
|
|
}
|