Shoubhit Dash 1 месяц назад
Родитель
Сommit
1d9dcd2a27

+ 86 - 54
packages/enterprise/src/core/share.ts

@@ -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(

+ 1 - 0
packages/enterprise/src/routes/api/[...path].ts

@@ -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))
     },
   )

+ 13 - 77
packages/enterprise/src/routes/share/[shareID].tsx

@@ -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()}

+ 26 - 4
packages/enterprise/test/core/share.test.ts

@@ -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 })

+ 25 - 7
packages/opencode/src/share/share-next.ts

@@ -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, [
       {

+ 15 - 2
packages/ui/src/components/markdown.tsx

@@ -44,6 +44,19 @@ function sanitize(html: string) {
   return DOMPurify.sanitize(html, config)
 }
 
+function escape(text: string) {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/\"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+}
+
+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

+ 1 - 1
packages/ui/src/components/session-review.tsx

@@ -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: {},
   })