368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
|
import { useParams, useSearchParams } from "@solidjs/router"
|
|
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
|
import type { FileSelection } from "@/context/file"
|
|
import { Persist, persisted } from "@/utils/persist"
|
|
import { useServerSDK } from "./server-sdk"
|
|
import type { ServerScope } from "@/utils/server-scope"
|
|
import { useSDK } from "./sdk"
|
|
import { useTabs, type Tab } from "./tabs"
|
|
import { ServerConnection } from "./server"
|
|
import { requireServerKey } from "@/utils/session-route"
|
|
import { useSettings } from "./settings"
|
|
|
|
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
|
|
sourcePath?: 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
|
|
)
|
|
}
|
|
|
|
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
|
switch (partA.type) {
|
|
case "text":
|
|
return partB.type === "text" && partA.content === partB.content
|
|
case "file":
|
|
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
|
|
case "agent":
|
|
return partB.type === "agent" && partA.name === partB.name
|
|
case "image":
|
|
return partB.type === "image" && partA.id === partB.id
|
|
}
|
|
}
|
|
|
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|
if (promptA.length !== promptB.length) return false
|
|
for (let i = 0; i < promptA.length; i++) {
|
|
if (!isPartEqual(promptA[i], promptB[i])) 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)
|
|
}
|
|
|
|
function contextItemKey(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)}`
|
|
}
|
|
|
|
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
|
return item.type === "file" && !!item.comment?.trim()
|
|
}
|
|
|
|
function createPromptActions(
|
|
setStore: SetStoreFunction<{
|
|
prompt: Prompt
|
|
cursor?: number
|
|
context: {
|
|
items: (ContextItem & { key: string })[]
|
|
}
|
|
}>,
|
|
) {
|
|
return {
|
|
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)
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
const WORKSPACE_KEY = "__workspace__"
|
|
const MAX_PROMPT_SESSIONS = 20
|
|
|
|
type PromptSession = ReturnType<typeof createPromptSession>
|
|
|
|
type PromptStore = {
|
|
prompt: Prompt
|
|
cursor?: number
|
|
context: {
|
|
items: (ContextItem & { key: string })[]
|
|
}
|
|
}
|
|
|
|
type Scope = { draftID: string } | { dir: string; id?: string }
|
|
|
|
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
|
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
|
if (!scope.id) return
|
|
return (
|
|
tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
|
|
({ type: "session", server, sessionId: scope.id } satisfies Tab)
|
|
)
|
|
}
|
|
|
|
function scopeKey(scope: Scope) {
|
|
if ("draftID" in scope) return `draft:${scope.draftID}`
|
|
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
|
}
|
|
|
|
type PromptCacheEntry = {
|
|
value: PromptSession
|
|
dispose: VoidFunction
|
|
}
|
|
|
|
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
|
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
|
}
|
|
|
|
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
|
const [store, setStore, _, ready] = persisted(
|
|
promptTarget(serverScope, scope),
|
|
createStore<PromptStore>(promptStore()),
|
|
)
|
|
|
|
return { ready, ...createPromptStateValue(store, setStore) }
|
|
}
|
|
|
|
export function createPromptReady(session: Accessor<PromptSession>) {
|
|
return Object.defineProperty(() => session().ready(), "promise", {
|
|
get: () => session().ready.promise,
|
|
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
|
}
|
|
|
|
function promptStore(): PromptStore {
|
|
return {
|
|
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
cursor: undefined,
|
|
context: {
|
|
items: [],
|
|
},
|
|
}
|
|
}
|
|
|
|
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
|
const actions = createPromptActions(setStore)
|
|
|
|
const value = {
|
|
current: () => store.prompt,
|
|
cursor: createMemo(() => store.cursor),
|
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
|
context: {
|
|
items: createMemo(() => store.context.items),
|
|
add(item: ContextItem) {
|
|
const key = contextItemKey(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))
|
|
},
|
|
removeComment(path: string, commentID: string) {
|
|
setStore("context", "items", (items) =>
|
|
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
|
)
|
|
},
|
|
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
|
setStore("context", "items", (items) =>
|
|
items.map((item) => {
|
|
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
|
const value = { ...item, ...next }
|
|
return { ...value, key: contextItemKey(value) }
|
|
}),
|
|
)
|
|
},
|
|
replaceComments(items: FileContextItem[]) {
|
|
setStore("context", "items", (current) => [
|
|
...current.filter((item) => !isCommentItem(item)),
|
|
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
|
])
|
|
},
|
|
},
|
|
set: actions.set,
|
|
reset: actions.reset,
|
|
capture: () => value,
|
|
}
|
|
return value
|
|
}
|
|
|
|
export function createPromptState() {
|
|
const [store, setStore] = createStore<PromptStore>(promptStore())
|
|
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
|
return {
|
|
ready,
|
|
...createPromptStateValue(store, setStore),
|
|
}
|
|
}
|
|
|
|
export const createTabPromptState = (
|
|
tabs: ReturnType<typeof useTabs>,
|
|
tab: Tab,
|
|
...args: Parameters<typeof createPromptSession>
|
|
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
|
|
|
|
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
|
|
name: "Prompt",
|
|
gate: false,
|
|
init: () => {
|
|
const params = useParams<{ serverKey?: string; id?: string }>()
|
|
const sdk = useSDK()
|
|
const [search] = useSearchParams<{ draftId?: string }>()
|
|
const serverSDK = useServerSDK()
|
|
const tabs = useTabs()
|
|
const settings = useSettings()
|
|
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 owner = getOwner()
|
|
const serverKey = () =>
|
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
|
const scope = () =>
|
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
|
const load = (scope: Scope) => {
|
|
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
|
if (current) {
|
|
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
|
}
|
|
|
|
const key = scopeKey(scope)
|
|
const existing = cache.get(key)
|
|
if (existing) {
|
|
cache.delete(key)
|
|
cache.set(key, existing)
|
|
return existing.value
|
|
}
|
|
|
|
const entry = createRoot(
|
|
(dispose) => ({
|
|
value: createPromptSession(serverSDK().scope, scope),
|
|
dispose,
|
|
}),
|
|
owner,
|
|
)
|
|
|
|
cache.set(key, entry)
|
|
prune()
|
|
return entry.value
|
|
}
|
|
|
|
const session = createMemo(() => load(scope()))
|
|
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
|
const ready = createPromptReady(session)
|
|
|
|
return {
|
|
ready,
|
|
capture: (scope?: Scope) => pick(scope).capture(),
|
|
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),
|
|
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
|
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
|
|
session().context.updateComment(path, commentID, next),
|
|
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
|
},
|
|
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
|
reset: (scope?: Scope) => pick(scope).reset(),
|
|
}
|
|
},
|
|
})
|