mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-06 06:33:59 +00:00
Compare commits
5 Commits
fix/git-fs
...
perf/share
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf5fd5a68 | ||
|
|
f78c2721c4 | ||
|
|
5ef1dcb9b7 | ||
|
|
6fa0057247 | ||
|
|
4ee503dd88 |
@@ -1,10 +1,8 @@
|
||||
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Identifier } from "@opencode-ai/util/identifier"
|
||||
import z from "zod"
|
||||
import { Storage } from "./storage"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
|
||||
export namespace Share {
|
||||
export const Info = z.object({
|
||||
@@ -38,6 +36,81 @@ export namespace Share {
|
||||
])
|
||||
export type Data = z.infer<typeof Data>
|
||||
|
||||
type Snapshot = {
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
function key(item: Data) {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `part/${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
|
||||
function merge(...items: Data[][]) {
|
||||
const map = new Map<string, Data>()
|
||||
for (const list of items) {
|
||||
for (const item of list) {
|
||||
map.set(key(item), item)
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, item]) => item)
|
||||
}
|
||||
|
||||
async function readSnapshot(shareID: string) {
|
||||
return (await Storage.read<Snapshot>(["share_snapshot", shareID]))?.data
|
||||
}
|
||||
|
||||
async function writeSnapshot(shareID: string, data: Data[]) {
|
||||
await Storage.write<Snapshot>(["share_snapshot", shareID], { data })
|
||||
}
|
||||
|
||||
async function legacy(shareID: string) {
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
if (list.length === 0) {
|
||||
if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data)
|
||||
return compaction.data
|
||||
}
|
||||
|
||||
const next = merge(
|
||||
compaction.data,
|
||||
await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) =>
|
||||
x.flatMap((item) => item ?? []),
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
Storage.write(["share_compaction", shareID], {
|
||||
event: list.at(-1)?.at(-1),
|
||||
data: next,
|
||||
}),
|
||||
writeSnapshot(shareID, next),
|
||||
])
|
||||
return next
|
||||
}
|
||||
|
||||
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
|
||||
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
|
||||
const info: Info = {
|
||||
@@ -47,7 +120,7 @@ export namespace Share {
|
||||
}
|
||||
const exists = await get(info.id)
|
||||
if (exists) throw new Errors.AlreadyExists(info.id)
|
||||
await Storage.write(["share", info.id], info)
|
||||
await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -60,8 +133,13 @@ export namespace Share {
|
||||
if (!share) throw new Errors.NotFound(body.id)
|
||||
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
|
||||
await Storage.remove(["share", body.id])
|
||||
const list = await Storage.list({ prefix: ["share_data", body.id] })
|
||||
for (const item of list) {
|
||||
const groups = await Promise.all([
|
||||
Storage.list({ prefix: ["share_snapshot", body.id] }),
|
||||
Storage.list({ prefix: ["share_compaction", body.id] }),
|
||||
Storage.list({ prefix: ["share_event", body.id] }),
|
||||
Storage.list({ prefix: ["share_data", body.id] }),
|
||||
])
|
||||
for (const item of groups.flat()) {
|
||||
await Storage.remove(item)
|
||||
}
|
||||
})
|
||||
@@ -75,59 +153,13 @@ export namespace Share {
|
||||
const share = await get(input.share.id)
|
||||
if (!share) throw new Errors.NotFound(input.share.id)
|
||||
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
|
||||
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
|
||||
const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
|
||||
await writeSnapshot(input.share.id, merge(data, input.data))
|
||||
},
|
||||
)
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
export async function data(shareID: string) {
|
||||
console.log("reading compaction")
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
console.log("reading pending events")
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
|
||||
console.log("compacting", list.length)
|
||||
|
||||
if (list.length > 0) {
|
||||
const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat())
|
||||
for (const item of data) {
|
||||
if (!item) continue
|
||||
const key = (item: Data) => {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
const id = key(item)
|
||||
const result = Binary.search(compaction.data, id, key)
|
||||
if (result.found) {
|
||||
compaction.data[result.index] = item
|
||||
} else {
|
||||
compaction.data.splice(result.index, 0, item)
|
||||
}
|
||||
}
|
||||
compaction.event = list.at(-1)?.at(-1)
|
||||
await Storage.write(["share_compaction", shareID], compaction)
|
||||
}
|
||||
return compaction.data
|
||||
return (await readSnapshot(shareID)) ?? legacy(shareID)
|
||||
}
|
||||
|
||||
export const syncOld = fn(
|
||||
|
||||
@@ -108,6 +108,7 @@ app
|
||||
validator("param", z.object({ shareID: z.string() })),
|
||||
async (c) => {
|
||||
const { shareID } = c.req.valid("param")
|
||||
c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400")
|
||||
return c.json(await Share.data(shareID))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,12 +5,11 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -20,11 +19,11 @@ import z from "zod"
|
||||
import NotFound from "../[...404]"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Base64 } from "js-base64"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||
@@ -54,12 +53,6 @@ const getData = query(async (shareID) => {
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
}
|
||||
session_diff_preload: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_diff_preload_split: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
@@ -79,12 +72,6 @@ const getData = query(async (shareID) => {
|
||||
session_diff: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload_split: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_status: {
|
||||
[share.sessionID]: {
|
||||
type: "idle",
|
||||
@@ -101,28 +88,6 @@ const getData = query(async (shareID) => {
|
||||
break
|
||||
case "session_diff":
|
||||
result.session_diff[share.sessionID] = item.data
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("unified"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload[share.sessionID] = r)),
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("split"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
|
||||
])
|
||||
break
|
||||
case "message":
|
||||
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
|
||||
@@ -143,17 +108,15 @@ const getData = query(async (shareID) => {
|
||||
}, "getShareData")
|
||||
|
||||
export default function () {
|
||||
getRequestEvent()?.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
|
||||
)
|
||||
|
||||
const params = useParams()
|
||||
const data = createAsync(async () => {
|
||||
if (!params.shareID) throw new Error("Missing shareID")
|
||||
const now = Date.now()
|
||||
const data = getData(params.shareID)
|
||||
console.log("getData", Date.now() - now)
|
||||
return data
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(data())
|
||||
return getData(params.shareID)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -241,22 +204,8 @@ export default function () {
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const splitDiffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
|
||||
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
|
||||
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -380,18 +329,9 @@ export default function () {
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
diffStyle={diffStyle()}
|
||||
onDiffStyleChange={setDiffStyle}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
@@ -419,11 +359,7 @@ export default function () {
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
|
||||
@@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
|
||||
data,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(1)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
|
||||
data: data2,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(2)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(2)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -194,6 +194,28 @@ describe.concurrent("core.share", () => {
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should migrate legacy event data into the snapshot", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
type: "part",
|
||||
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
|
||||
},
|
||||
]
|
||||
|
||||
await Storage.remove(["share_snapshot", share.id])
|
||||
await Storage.write(["share_event", share.id, Identifier.descending()], data)
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should throw error for invalid secret", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { ulid } from "ulid"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
@@ -122,20 +121,35 @@ export namespace ShareNext {
|
||||
data: SDK.Model[]
|
||||
}
|
||||
|
||||
function key(item: Data) {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `part/${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
|
||||
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
|
||||
async function sync(sessionID: string, data: Data[]) {
|
||||
if (disabled) return
|
||||
const existing = queue.get(sessionID)
|
||||
if (existing) {
|
||||
for (const item of data) {
|
||||
existing.data.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
existing.data.set(key(item), item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dataMap = new Map<string, Data>()
|
||||
for (const item of data) {
|
||||
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
dataMap.set(key(item), item)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
@@ -182,10 +196,14 @@ export namespace ShareNext {
|
||||
const diffs = await Session.diff(sessionID)
|
||||
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
|
||||
const models = await Promise.all(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
|
||||
Array.from(
|
||||
new Map(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
|
||||
).values(),
|
||||
).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)),
|
||||
)
|
||||
await sync(sessionID, [
|
||||
{
|
||||
|
||||
@@ -44,6 +44,19 @@ function sanitize(html: string) {
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
@@ -237,7 +250,7 @@ export function Markdown(
|
||||
const [html] = createResource(
|
||||
() => local.text,
|
||||
async (markdown) => {
|
||||
if (isServer) return ""
|
||||
if (isServer) return fallback(markdown)
|
||||
|
||||
const hash = checksum(markdown)
|
||||
const key = local.cacheKey ?? hash
|
||||
@@ -255,7 +268,7 @@ export function Markdown(
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
},
|
||||
{ initialValue: "" },
|
||||
{ initialValue: isServer ? fallback(local.text) : "" },
|
||||
)
|
||||
|
||||
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const searchHandles = new Map<string, FileSearchHandle>()
|
||||
const readyFiles = new Set<string>()
|
||||
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
|
||||
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
||||
open: [],
|
||||
force: {},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user