247 lines
6.6 KiB
TypeScript
247 lines
6.6 KiB
TypeScript
import { createStore } from "solid-js/store"
|
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
|
import { useParams } from "@solidjs/router"
|
|
import type { FileSelection } from "@/context/file"
|
|
import { Persist, persisted } from "@/utils/persist"
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
|
|
interface PartBase {
|
|
content: string
|
|
start: number
|
|
end: number
|
|
}
|
|
|
|
export interface TextPart extends PartBase {
|
|
type: "text"
|
|
}
|
|
|
|
export interface FileAttachmentPart extends PartBase {
|
|
type: "file"
|
|
path: string
|
|
selection?: FileSelection
|
|
}
|
|
|
|
export interface AgentPart extends PartBase {
|
|
type: "agent"
|
|
name: string
|
|
}
|
|
|
|
export interface ImageAttachmentPart {
|
|
type: "image"
|
|
id: string
|
|
filename: string
|
|
mime: string
|
|
dataUrl: string
|
|
}
|
|
|
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
|
export type Prompt = ContentPart[]
|
|
|
|
export type FileContextItem = {
|
|
type: "file"
|
|
path: string
|
|
selection?: FileSelection
|
|
comment?: string
|
|
commentID?: string
|
|
commentOrigin?: "review" | "file"
|
|
preview?: string
|
|
}
|
|
|
|
export type ContextItem = FileContextItem
|
|
|
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|
|
|
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
|
if (!a && !b) return true
|
|
if (!a || !b) return false
|
|
return (
|
|
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
|
)
|
|
}
|
|
|
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|
if (promptA.length !== promptB.length) return false
|
|
for (let i = 0; i < promptA.length; i++) {
|
|
const partA = promptA[i]
|
|
const partB = promptB[i]
|
|
if (partA.type !== partB.type) return false
|
|
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
|
return false
|
|
}
|
|
if (partA.type === "file") {
|
|
const fileA = partA as FileAttachmentPart
|
|
const fileB = partB as FileAttachmentPart
|
|
if (fileA.path !== fileB.path) return false
|
|
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
|
|
}
|
|
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
|
return false
|
|
}
|
|
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function cloneSelection(selection?: FileSelection) {
|
|
if (!selection) return undefined
|
|
return { ...selection }
|
|
}
|
|
|
|
function clonePart(part: ContentPart): ContentPart {
|
|
if (part.type === "text") return { ...part }
|
|
if (part.type === "image") return { ...part }
|
|
if (part.type === "agent") return { ...part }
|
|
return {
|
|
...part,
|
|
selection: cloneSelection(part.selection),
|
|
}
|
|
}
|
|
|
|
function clonePrompt(prompt: Prompt): Prompt {
|
|
return prompt.map(clonePart)
|
|
}
|
|
|
|
const WORKSPACE_KEY = "__workspace__"
|
|
const MAX_PROMPT_SESSIONS = 20
|
|
|
|
type PromptSession = ReturnType<typeof createPromptSession>
|
|
|
|
type PromptCacheEntry = {
|
|
value: PromptSession
|
|
dispose: VoidFunction
|
|
}
|
|
|
|
function createPromptSession(dir: string, id: string | undefined) {
|
|
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
|
|
|
const [store, setStore, _, ready] = persisted(
|
|
Persist.scoped(dir, id, "prompt", [legacy]),
|
|
createStore<{
|
|
prompt: Prompt
|
|
cursor?: number
|
|
context: {
|
|
items: (ContextItem & { key: string })[]
|
|
}
|
|
}>({
|
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
cursor: undefined,
|
|
context: {
|
|
items: [],
|
|
},
|
|
}),
|
|
)
|
|
|
|
function keyForItem(item: ContextItem) {
|
|
if (item.type !== "file") return item.type
|
|
const start = item.selection?.startLine
|
|
const end = item.selection?.endLine
|
|
const key = `${item.type}:${item.path}:${start}:${end}`
|
|
|
|
if (item.commentID) {
|
|
return `${key}:c=${item.commentID}`
|
|
}
|
|
|
|
const comment = item.comment?.trim()
|
|
if (!comment) return key
|
|
const digest = checksum(comment) ?? comment
|
|
return `${key}:c=${digest.slice(0, 8)}`
|
|
}
|
|
|
|
return {
|
|
ready,
|
|
current: createMemo(() => store.prompt),
|
|
cursor: createMemo(() => store.cursor),
|
|
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
|
context: {
|
|
items: createMemo(() => store.context.items),
|
|
add(item: ContextItem) {
|
|
const key = keyForItem(item)
|
|
if (store.context.items.find((x) => x.key === key)) return
|
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
},
|
|
remove(key: string) {
|
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
},
|
|
},
|
|
set(prompt: Prompt, cursorPosition?: number) {
|
|
const next = clonePrompt(prompt)
|
|
batch(() => {
|
|
setStore("prompt", next)
|
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
})
|
|
},
|
|
reset() {
|
|
batch(() => {
|
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
setStore("cursor", 0)
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
|
name: "Prompt",
|
|
gate: false,
|
|
init: () => {
|
|
const params = useParams()
|
|
const cache = new Map<string, PromptCacheEntry>()
|
|
|
|
const disposeAll = () => {
|
|
for (const entry of cache.values()) {
|
|
entry.dispose()
|
|
}
|
|
cache.clear()
|
|
}
|
|
|
|
onCleanup(disposeAll)
|
|
|
|
const prune = () => {
|
|
while (cache.size > MAX_PROMPT_SESSIONS) {
|
|
const first = cache.keys().next().value
|
|
if (!first) return
|
|
const entry = cache.get(first)
|
|
entry?.dispose()
|
|
cache.delete(first)
|
|
}
|
|
}
|
|
|
|
const load = (dir: string, id: string | undefined) => {
|
|
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
const existing = cache.get(key)
|
|
if (existing) {
|
|
cache.delete(key)
|
|
cache.set(key, existing)
|
|
return existing.value
|
|
}
|
|
|
|
const entry = createRoot((dispose) => ({
|
|
value: createPromptSession(dir, id),
|
|
dispose,
|
|
}))
|
|
|
|
cache.set(key, entry)
|
|
prune()
|
|
return entry.value
|
|
}
|
|
|
|
const session = createMemo(() => load(params.dir!, params.id))
|
|
|
|
return {
|
|
ready: () => session().ready(),
|
|
current: () => session().current(),
|
|
cursor: () => session().cursor(),
|
|
dirty: () => session().dirty(),
|
|
context: {
|
|
items: () => session().context.items(),
|
|
add: (item: ContextItem) => session().context.add(item),
|
|
remove: (key: string) => session().context.remove(key),
|
|
},
|
|
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
|
reset: () => session().reset(),
|
|
}
|
|
},
|
|
})
|