204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
|
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
|
|
|
type Inline =
|
|
| {
|
|
type: "file"
|
|
start: number
|
|
end: number
|
|
value: string
|
|
path: string
|
|
selection?: {
|
|
startLine: number
|
|
endLine: number
|
|
startChar: number
|
|
endChar: number
|
|
}
|
|
}
|
|
| {
|
|
type: "agent"
|
|
start: number
|
|
end: number
|
|
value: string
|
|
name: string
|
|
}
|
|
|
|
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
|
const queryIndex = url.indexOf("?")
|
|
if (queryIndex === -1) return undefined
|
|
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
|
const startLine = Number(params.get("start"))
|
|
const endLine = Number(params.get("end"))
|
|
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
|
|
return {
|
|
startLine,
|
|
endLine,
|
|
startChar: 0,
|
|
endChar: 0,
|
|
}
|
|
}
|
|
|
|
function textPartValue(parts: Part[]) {
|
|
const candidates = parts
|
|
.filter((part): part is TextPart => part.type === "text")
|
|
.filter((part) => !part.synthetic && !part.ignored)
|
|
return candidates.reduce((best: TextPart | undefined, part) => {
|
|
if (!best) return part
|
|
if (part.text.length > best.text.length) return part
|
|
return best
|
|
}, undefined)
|
|
}
|
|
|
|
/**
|
|
* Extract prompt content from message parts for restoring into the prompt input.
|
|
* This is used by undo to restore the original user prompt.
|
|
*/
|
|
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
|
|
const textPart = textPartValue(parts)
|
|
const text = textPart?.text ?? ""
|
|
const directory = opts?.directory
|
|
const attachmentName = opts?.attachmentName ?? "attachment"
|
|
|
|
const toRelative = (path: string) => {
|
|
if (!directory) return path
|
|
|
|
const prefix = directory.endsWith("/") ? directory : directory + "/"
|
|
if (path.startsWith(prefix)) return path.slice(prefix.length)
|
|
|
|
if (path.startsWith(directory)) {
|
|
const next = path.slice(directory.length)
|
|
if (next.startsWith("/")) return next.slice(1)
|
|
return next
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
const inline: Inline[] = []
|
|
const images: ImageAttachmentPart[] = []
|
|
|
|
for (const part of parts) {
|
|
if (part.type === "file") {
|
|
const filePart = part as FilePart
|
|
const sourceText = filePart.source?.text
|
|
if (sourceText) {
|
|
const value = sourceText.value
|
|
const start = sourceText.start
|
|
const end = sourceText.end
|
|
let path = value
|
|
if (value.startsWith("@")) path = value.slice(1)
|
|
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
|
|
path = filePart.source.path
|
|
}
|
|
inline.push({
|
|
type: "file",
|
|
start,
|
|
end,
|
|
value,
|
|
path: toRelative(path),
|
|
selection: selectionFromFileUrl(filePart.url),
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (filePart.url.startsWith("data:")) {
|
|
images.push({
|
|
type: "image",
|
|
id: filePart.id,
|
|
filename: filePart.filename ?? attachmentName,
|
|
mime: filePart.mime,
|
|
dataUrl: filePart.url,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (part.type === "agent") {
|
|
const agentPart = part as MessageAgentPart
|
|
const source = agentPart.source
|
|
if (!source) continue
|
|
inline.push({
|
|
type: "agent",
|
|
start: source.start,
|
|
end: source.end,
|
|
value: source.value,
|
|
name: agentPart.name,
|
|
})
|
|
}
|
|
}
|
|
|
|
inline.sort((a, b) => {
|
|
if (a.start !== b.start) return a.start - b.start
|
|
return a.end - b.end
|
|
})
|
|
|
|
const result: Prompt = []
|
|
let position = 0
|
|
let cursor = 0
|
|
|
|
const pushText = (content: string) => {
|
|
if (!content) return
|
|
result.push({
|
|
type: "text",
|
|
content,
|
|
start: position,
|
|
end: position + content.length,
|
|
})
|
|
position += content.length
|
|
}
|
|
|
|
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
|
|
const content = item.value
|
|
const attachment: FileAttachmentPart = {
|
|
type: "file",
|
|
path: item.path,
|
|
content,
|
|
start: position,
|
|
end: position + content.length,
|
|
selection: item.selection,
|
|
}
|
|
result.push(attachment)
|
|
position += content.length
|
|
}
|
|
|
|
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
|
|
const content = item.value
|
|
const mention: AgentPart = {
|
|
type: "agent",
|
|
name: item.name,
|
|
content,
|
|
start: position,
|
|
end: position + content.length,
|
|
}
|
|
result.push(mention)
|
|
position += content.length
|
|
}
|
|
|
|
for (const item of inline) {
|
|
if (item.start < 0 || item.end < item.start) continue
|
|
|
|
const expected = item.value
|
|
if (!expected) continue
|
|
|
|
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
|
|
const start = mismatch ? text.indexOf(expected, cursor) : item.start
|
|
if (start === -1) continue
|
|
const end = mismatch ? start + expected.length : item.end
|
|
|
|
pushText(text.slice(cursor, start))
|
|
|
|
if (item.type === "file") pushFile(item)
|
|
if (item.type === "agent") pushAgent(item)
|
|
|
|
cursor = end
|
|
}
|
|
|
|
pushText(text.slice(cursor))
|
|
|
|
if (result.length === 0) {
|
|
result.push({ type: "text", content: "", start: 0, end: 0 })
|
|
}
|
|
|
|
if (images.length === 0) return result
|
|
return [...result, ...images]
|
|
}
|