diff options
| author | Dax <[email protected]> | 2025-11-21 20:41:27 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-11-21 20:41:27 -0500 |
| commit | 49408c00e964093c654ee270d545c0e29857e61f (patch) | |
| tree | 600b2a94cd082c0af221fd84fd5346cfe8f9ace4 /packages/enterprise/src | |
| parent | 76192fbcedff945b5d529a5d733deb2488ee9c8a (diff) | |
| download | opencode-49408c00e964093c654ee270d545c0e29857e61f.tar.gz opencode-49408c00e964093c654ee270d545c0e29857e61f.zip | |
enterprise (#4617)
Co-authored-by: GitHub Action <[email protected]>
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/enterprise/src')
| -rw-r--r-- | packages/enterprise/src/app.css | 18 | ||||
| -rw-r--r-- | packages/enterprise/src/app.tsx | 28 | ||||
| -rw-r--r-- | packages/enterprise/src/core/share.ts | 139 | ||||
| -rw-r--r-- | packages/enterprise/src/core/storage.ts | 134 | ||||
| -rw-r--r-- | packages/enterprise/src/entry-client.tsx | 4 | ||||
| -rw-r--r-- | packages/enterprise/src/entry-server.tsx | 22 | ||||
| -rw-r--r-- | packages/enterprise/src/global.d.ts | 1 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/[...404].tsx | 25 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/api/[...path].ts | 152 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/share.tsx | 5 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/share/[sessionID].tsx | 172 |
11 files changed, 700 insertions, 0 deletions
diff --git a/packages/enterprise/src/app.css b/packages/enterprise/src/app.css new file mode 100644 index 000000000..01c61e62c --- /dev/null +++ b/packages/enterprise/src/app.css @@ -0,0 +1,18 @@ +@import "@opencode-ai/ui/styles/tailwind"; + +:root { + --background-rgb: 214, 219, 220; + --foreground-rgb: 0, 0, 0; +} + +@media (prefers-color-scheme: dark) { + :root { + --background-rgb: 0, 0, 0; + --foreground-rgb: 255, 255, 255; + } +} + +body { + /* background: rgb(var(--background-rgb)); */ + /* color: rgb(var(--foreground-rgb)); */ +} diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx new file mode 100644 index 000000000..fd10899f2 --- /dev/null +++ b/packages/enterprise/src/app.tsx @@ -0,0 +1,28 @@ +import { Router } from "@solidjs/router" +import { FileRoutes } from "@solidjs/start/router" +import { Suspense } from "solid-js" +import { Fonts } from "@opencode-ai/ui/fonts" +import { MetaProvider } from "@solidjs/meta" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import "./app.css" + +export default function App() { + return ( + <Router + root={(props) => ( + <> + <Suspense> + <MarkedProvider> + <MetaProvider> + <Fonts /> + {props.children} + </MetaProvider> + </MarkedProvider> + </Suspense> + </> + )} + > + <FileRoutes /> + </Router> + ) +} diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts new file mode 100644 index 000000000..a7dfbfdcc --- /dev/null +++ b/packages/enterprise/src/core/share.ts @@ -0,0 +1,139 @@ +import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk" +import { fn } from "@opencode-ai/util/fn" +import { iife } from "@opencode-ai/util/iife" +import z from "zod" +import { Storage } from "./storage" + +export namespace Share { + export const Info = z.object({ + id: z.string(), + secret: z.string(), + }) + export type Info = z.infer<typeof Info> + + export const Data = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("session"), + data: z.custom<Session>(), + }), + z.object({ + type: z.literal("message"), + data: z.custom<Message>(), + }), + z.object({ + type: z.literal("part"), + data: z.custom<Part>(), + }), + z.object({ + type: z.literal("session_diff"), + data: z.custom<FileDiff[]>(), + }), + z.object({ + type: z.literal("session_status"), + data: z.custom<SessionStatus>(), + }), + ]) + export type Data = z.infer<typeof Data> + + export const create = fn(Info.pick({ id: true }), async (body) => { + const info: Info = { + id: body.id, + secret: crypto.randomUUID(), + } + const exists = await get(info.id) + if (exists) throw new Errors.AlreadyExists(info.id) + await Storage.write(["share", info.id], info) + console.log("created share", info.id) + return info + }) + + async function get(sessionID: string) { + return Storage.read<Info>(["share", sessionID]) + } + + export const remove = fn(Info.pick({ id: true, secret: true }), async (body) => { + const share = await get(body.id) + 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(["share_data", body.id]) + for (const item of list) { + await Storage.remove(item) + } + }) + + export async function data(sessionID: string) { + const list = await Storage.list(["share_data", sessionID]) + const promises = [] + for (const item of list) { + promises.push( + iife(async () => { + const [, , type] = item + return { + type: type as any, + data: await Storage.read<any>(item), + } as Data + }), + ) + } + return await Promise.all(promises) + } + + export const sync = fn( + z.object({ + share: Info, + data: Data.array(), + }), + async (input) => { + 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) + const promises = [] + for (const item of input.data) { + promises.push( + iife(async () => { + switch (item.type) { + case "session": + await Storage.write(["share_data", input.share.id, "session"], item.data) + break + case "message": + await Storage.write(["share_data", input.share.id, "message", item.data.id], item.data) + break + case "part": + await Storage.write( + ["share_data", input.share.id, "part", item.data.messageID, item.data.id], + item.data, + ) + break + case "session_diff": + await Storage.write(["share_data", input.share.id, "session_diff"], item.data) + break + case "session_status": + await Storage.write(["share_data", input.share.id, "session_status"], item.data) + break + } + }), + ) + } + await Promise.all(promises) + }, + ) + + export const Errors = { + NotFound: class extends Error { + constructor(public id: string) { + super(`Share not found: ${id}`) + } + }, + InvalidSecret: class extends Error { + constructor(public id: string) { + super(`Share secret invalid: ${id}`) + } + }, + AlreadyExists: class extends Error { + constructor(public id: string) { + super(`Share already exists: ${id}`) + } + }, + } +} diff --git a/packages/enterprise/src/core/storage.ts b/packages/enterprise/src/core/storage.ts new file mode 100644 index 000000000..fb33ef389 --- /dev/null +++ b/packages/enterprise/src/core/storage.ts @@ -0,0 +1,134 @@ +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, +} from "@aws-sdk/client-s3" +import { lazy } from "@opencode-ai/util/lazy" + +export namespace Storage { + export interface Adapter { + read(path: string): Promise<string | undefined> + write(path: string, value: string): Promise<void> + remove(path: string): Promise<void> + list(prefix: string): Promise<string[]> + } + + function createAdapter(client: S3Client, bucket: string): Adapter { + return { + async read(path: string): Promise<string | undefined> { + try { + console.log("reading", bucket, path) + const command = new GetObjectCommand({ + Bucket: bucket, + Key: path, + }) + const response = await client.send(command) + if (!response.Body) return undefined + return response.Body.transformToString() + } catch (e: any) { + if (e.name === "NoSuchKey") return undefined + throw e + } + }, + + async write(path: string, value: string): Promise<void> { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: path, + Body: value, + ContentType: "application/json", + }) + await client.send(command) + }, + + async remove(path: string): Promise<void> { + const command = new DeleteObjectCommand({ + Bucket: bucket, + Key: path, + }) + await client.send(command) + }, + + async list(prefix: string): Promise<string[]> { + const command = new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix, + }) + const response = await client.send(command) + return response.Contents?.map((c) => c.Key!) || [] + }, + } + } + + function s3(): Adapter { + const bucket = process.env.OPENCODE_STORAGE_BUCKET! + const client = new S3Client({ + region: process.env.OPENCODE_STORAGE_REGION, + credentials: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID + ? { + accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!, + secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!, + } + : undefined, + }) + return createAdapter(client, bucket) + } + + function r2() { + const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID! + const accessKeyId = process.env.OPENCODE_STORAGE_ACCESS_KEY_ID! + const secretAccessKey = process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY! + const bucket = process.env.OPENCODE_STORAGE_BUCKET! + + const client = new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId, + secretAccessKey, + }, + }) + return createAdapter(client, bucket) + } + + const adapter = lazy(() => { + const type = process.env.OPENCODE_STORAGE_ADAPTER + if (type === "r2") return r2() + if (type === "s3") return s3() + throw new Error("No storage adapter configured") + }) + + function resolve(key: string[]) { + return key.join("/") + ".json" + } + + export async function read<T>(key: string[]) { + const result = await adapter().read(resolve(key)) + if (!result) return undefined + return JSON.parse(result) as T + } + + export function write<T>(key: string[], value: T) { + return adapter().write(resolve(key), JSON.stringify(value)) + } + + export function remove(key: string[]) { + return adapter().remove(resolve(key)) + } + + export async function list(prefix: string[]) { + const p = prefix.join("/") + (prefix.length ? "/" : "") + const result = await adapter().list(p) + return result.map((x) => x.replace(/\.json$/, "").split("/")) + } + + export async function update<T>(key: string[], fn: (draft: T) => void) { + const val = await read<T>(key) + if (!val) throw new Error("Not found") + fn(val) + await write(key, val) + return val + } +} diff --git a/packages/enterprise/src/entry-client.tsx b/packages/enterprise/src/entry-client.tsx new file mode 100644 index 000000000..642deacf7 --- /dev/null +++ b/packages/enterprise/src/entry-client.tsx @@ -0,0 +1,4 @@ +// @refresh reload +import { mount, StartClient } from "@solidjs/start/client" + +mount(() => <StartClient />, document.getElementById("app")!) diff --git a/packages/enterprise/src/entry-server.tsx b/packages/enterprise/src/entry-server.tsx new file mode 100644 index 000000000..9f6d3b42e --- /dev/null +++ b/packages/enterprise/src/entry-server.tsx @@ -0,0 +1,22 @@ +// @refresh reload +import { createHandler, StartServer } from "@solidjs/start/server" + +export default createHandler(() => ( + <StartServer + document={({ assets, children, scripts }) => ( + <html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.ico" /> + <title>OpenCode</title> + {assets} + </head> + <body class="antialiased overscroll-none select-none text-12-regular"> + <div id="app">{children}</div> + {scripts} + </body> + </html> + )} + /> +)) diff --git a/packages/enterprise/src/global.d.ts b/packages/enterprise/src/global.d.ts new file mode 100644 index 000000000..dc6f10c22 --- /dev/null +++ b/packages/enterprise/src/global.d.ts @@ -0,0 +1 @@ +/// <reference types="@solidjs/start/env" /> diff --git a/packages/enterprise/src/routes/[...404].tsx b/packages/enterprise/src/routes/[...404].tsx new file mode 100644 index 000000000..d81a5f3b4 --- /dev/null +++ b/packages/enterprise/src/routes/[...404].tsx @@ -0,0 +1,25 @@ +import { A } from "@solidjs/router" + +export default function NotFound() { + return ( + <main class="text-center mx-auto text-gray-700 p-4"> + <h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">Not Found</h1> + <p class="mt-8"> + Visit{" "} + <a href="https://solidjs.com" target="_blank" class="text-sky-600 hover:underline"> + solidjs.com + </a>{" "} + to learn how to build Solid apps. + </p> + <p class="my-4"> + <A href="/" class="text-sky-600 hover:underline"> + Home + </A> + {" - "} + <A href="/about" class="text-sky-600 hover:underline"> + About Page + </A> + </p> + </main> + ) +} diff --git a/packages/enterprise/src/routes/api/[...path].ts b/packages/enterprise/src/routes/api/[...path].ts new file mode 100644 index 000000000..bbca171bd --- /dev/null +++ b/packages/enterprise/src/routes/api/[...path].ts @@ -0,0 +1,152 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Hono } from "hono" +import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi" +import { validator } from "hono-openapi" +import z from "zod" +import { cors } from "hono/cors" +import { Share } from "~/core/share" + +const app = new Hono() + +app + .basePath("/api") + .use(cors()) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "Opencode Enterprise API", + version: "1.0.0", + description: "Opencode Enterprise API endpoints", + }, + openapi: "3.1.1", + }, + }), + ) + .post( + "/share", + describeRoute({ + description: "Create a share", + operationId: "share.create", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: resolver( + z + .object({ + url: z.string(), + secret: z.string(), + }) + .meta({ ref: "Share" }), + ), + }, + }, + }, + }, + }), + validator("json", z.object({ sessionID: z.string() })), + async (c) => { + const body = c.req.valid("json") + const share = await Share.create({ id: body.sessionID }) + const protocol = c.req.header("x-forwarded-proto") ?? c.req.header("x-forwarded-protocol") ?? "https" + const host = c.req.header("x-forwarded-host") ?? c.req.header("host") + return c.json({ + secret: share.secret, + url: `${protocol}://${host}/share/${share.id}`, + }) + }, + ) + .post( + "/share/:sessionID/sync", + describeRoute({ + description: "Sync share data", + operationId: "share.sync", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: resolver(z.object({})), + }, + }, + }, + }, + }), + validator("param", z.object({ sessionID: z.string() })), + validator("json", z.object({ secret: z.string(), data: Share.Data.array() })), + async (c) => { + const { sessionID } = c.req.valid("param") + const body = c.req.valid("json") + await Share.sync({ + share: { id: sessionID, secret: body.secret }, + data: body.data, + }) + return c.json({}) + }, + ) + .get( + "/share/:sessionID/data", + describeRoute({ + description: "Get share data", + operationId: "share.data", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: resolver(z.array(Share.Data)), + }, + }, + }, + }, + }), + validator("param", z.object({ sessionID: z.string() })), + async (c) => { + const { sessionID } = c.req.valid("param") + return c.json(await Share.data(sessionID)) + }, + ) + .delete( + "/share/:sessionID", + describeRoute({ + description: "Remove a share", + operationId: "share.remove", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: resolver(z.object({})), + }, + }, + }, + }, + }), + validator("param", z.object({ sessionID: z.string() })), + validator("json", z.object({ secret: z.string() })), + async (c) => { + const { sessionID } = c.req.valid("param") + const body = c.req.valid("json") + await Share.remove({ id: sessionID, secret: body.secret }) + return c.json({}) + }, + ) + +export function GET(event: APIEvent) { + return app.fetch(event.request) +} + +export function POST(event: APIEvent) { + return app.fetch(event.request) +} + +export function PUT(event: APIEvent) { + return app.fetch(event.request) +} + +export async function DELETE(event: APIEvent) { + return app.fetch(event.request) +} diff --git a/packages/enterprise/src/routes/share.tsx b/packages/enterprise/src/routes/share.tsx new file mode 100644 index 000000000..26e3d61a8 --- /dev/null +++ b/packages/enterprise/src/routes/share.tsx @@ -0,0 +1,5 @@ +import { ParentProps } from "solid-js" + +export default function Share(props: ParentProps) { + return props.children +} diff --git a/packages/enterprise/src/routes/share/[sessionID].tsx b/packages/enterprise/src/routes/share/[sessionID].tsx new file mode 100644 index 000000000..65d894b54 --- /dev/null +++ b/packages/enterprise/src/routes/share/[sessionID].tsx @@ -0,0 +1,172 @@ +import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk" +import { SessionTimeline } from "@opencode-ai/ui/session-timeline" +import { SessionReview } from "@opencode-ai/ui/session-review" +import { DataProvider, useData } from "@opencode-ai/ui/context" +import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router" +import { createMemo, Show } 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 { iife } from "@opencode-ai/util/iife" +import { Binary } from "@opencode-ai/util/binary" +import { DateTime } from "luxon" + +const getData = query(async (sessionID) => { + const data = await Share.data(sessionID) + const result: { + session: Session[] + session_diff: { + [sessionID: string]: FileDiff[] + } + session_status: { + [sessionID: string]: SessionStatus + } + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } + } = { + session: [], + session_diff: { + [sessionID]: [], + }, + session_status: { + [sessionID]: { + type: "idle", + }, + }, + message: {}, + part: {}, + } + + for (const item of data) { + switch (item.type) { + case "session": + result.session.push(item.data) + break + case "session_diff": + result.session_diff[sessionID] = item.data + break + case "session_status": + result.session_status[sessionID] = item.data + break + case "message": + result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? [] + result.message[item.data.sessionID].push(item.data) + break + case "part": + result.part[item.data.messageID] = result.part[item.data.messageID] ?? [] + result.part[item.data.messageID].push(item.data) + break + } + } + return result +}, "getShareData") + +export const route = { + preload: ({ params }) => getData(params.sessionID), +} satisfies RouteDefinition + +export default function () { + const params = useParams() + const data = createAsync(async () => { + if (!params.sessionID) return + return getData(params.sessionID) + }) + + return ( + <Show when={data()}> + {(data) => ( + <DataProvider data={data()}> + {iife(() => { + const data = useData() + const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id)) + if (!match().found) throw new Error(`Session ${params.sessionID} not found`) + const info = createMemo(() => data.session[match().index]) + const firstUserMessage = createMemo(() => + data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0), + ) + const provider = createMemo(() => firstUserMessage()?.model?.providerID) + const model = createMemo(() => firstUserMessage()?.model?.modelID) + const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? []) + + return ( + <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col"> + <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base"> + <div class=""> + <a href="https://opencode.ai"> + <Mark /> + </a> + </div> + <div class="flex gap-3 items-center"> + <IconButton + as={"a"} + href="https://github.com/sst/opencode" + target="_blank" + icon="github" + variant="ghost" + /> + <IconButton + as={"a"} + href="https://opencode.ai/discord" + target="_blank" + icon="discord" + variant="ghost" + /> + </div> + </header> + <div class="select-text flex flex-col flex-1 min-h-0"> + <div class="w-full flex-1 min-h-0 flex"> + <div + classList={{ + "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true, + "px-21 @4xl:px-6 max-w-2xl": diffs().length > 0, + "px-6 max-w-2xl": diffs().length === 0, + }} + > + <div class="flex flex-col gap-4 shrink-0"> + <div class="h-8 flex gap-4 items-center justify-start self-stretch"> + <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base"> + <Mark class="shrink-0 w-3 my-0.5" /> + <div class="text-12-mono text-text-base">v{info().version}</div> + </div> + <div class="flex gap-2 items-center"> + <img + src={`https://models.dev/logos/${provider()}.svg`} + class="size-4 shrink-0 dark:invert" + /> + <div class="text-12-regular text-text-base">{model()}</div> + </div> + <div class="text-12-regular text-text-weaker"> + {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} + </div> + </div> + <div class="text-left text-16-medium text-text-strong">{info().title}</div> + </div> + <SessionTimeline + sessionID={params.sessionID!} + classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }} + expanded + > + <div class="flex items-center justify-center pb-8 shrink-0"> + <Logo class="w-58.5 opacity-12" /> + </div> + </SessionTimeline> + </div> + <Show when={diffs().length}> + <div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base"> + <SessionReview diffs={diffs()} class="pb-20" /> + </div> + </Show> + </div> + </div> + </div> + ) + })} + </DataProvider> + )} + </Show> + ) +} |
