From 49408c00e964093c654ee270d545c0e29857e61f Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 21 Nov 2025 20:41:27 -0500 Subject: enterprise (#4617) Co-authored-by: GitHub Action Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- packages/enterprise/src/app.css | 18 +++ packages/enterprise/src/app.tsx | 28 ++++ packages/enterprise/src/core/share.ts | 139 +++++++++++++++++ packages/enterprise/src/core/storage.ts | 134 ++++++++++++++++ packages/enterprise/src/entry-client.tsx | 4 + packages/enterprise/src/entry-server.tsx | 22 +++ packages/enterprise/src/global.d.ts | 1 + packages/enterprise/src/routes/[...404].tsx | 25 +++ packages/enterprise/src/routes/api/[...path].ts | 152 ++++++++++++++++++ packages/enterprise/src/routes/share.tsx | 5 + .../enterprise/src/routes/share/[sessionID].tsx | 172 +++++++++++++++++++++ 11 files changed, 700 insertions(+) create mode 100644 packages/enterprise/src/app.css create mode 100644 packages/enterprise/src/app.tsx create mode 100644 packages/enterprise/src/core/share.ts create mode 100644 packages/enterprise/src/core/storage.ts create mode 100644 packages/enterprise/src/entry-client.tsx create mode 100644 packages/enterprise/src/entry-server.tsx create mode 100644 packages/enterprise/src/global.d.ts create mode 100644 packages/enterprise/src/routes/[...404].tsx create mode 100644 packages/enterprise/src/routes/api/[...path].ts create mode 100644 packages/enterprise/src/routes/share.tsx create mode 100644 packages/enterprise/src/routes/share/[sessionID].tsx (limited to 'packages/enterprise/src') 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 ( + ( + <> + + + + + {props.children} + + + + + )} + > + + + ) +} 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 + + export const Data = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("session"), + data: z.custom(), + }), + z.object({ + type: z.literal("message"), + data: z.custom(), + }), + z.object({ + type: z.literal("part"), + data: z.custom(), + }), + z.object({ + type: z.literal("session_diff"), + data: z.custom(), + }), + z.object({ + type: z.literal("session_status"), + data: z.custom(), + }), + ]) + export type Data = z.infer + + 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(["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(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 + write(path: string, value: string): Promise + remove(path: string): Promise + list(prefix: string): Promise + } + + function createAdapter(client: S3Client, bucket: string): Adapter { + return { + async read(path: string): Promise { + 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 { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: path, + Body: value, + ContentType: "application/json", + }) + await client.send(command) + }, + + async remove(path: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: bucket, + Key: path, + }) + await client.send(command) + }, + + async list(prefix: string): Promise { + 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(key: string[]) { + const result = await adapter().read(resolve(key)) + if (!result) return undefined + return JSON.parse(result) as T + } + + export function write(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(key: string[], fn: (draft: T) => void) { + const val = await read(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(() => , 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(() => ( + ( + + + + + + OpenCode + {assets} + + +
{children}
+ {scripts} + + + )} + /> +)) 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 @@ +/// 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 ( +
+

Not Found

+

+ Visit{" "} + + solidjs.com + {" "} + to learn how to build Solid apps. +

+

+ + Home + + {" - "} + + About Page + +

+
+ ) +} 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 ( + + {(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 ( +
+
+
+ + + +
+
+ + +
+
+
+
+
0, + "px-6 max-w-2xl": diffs().length === 0, + }} + > +
+
+
+ +
v{info().version}
+
+
+ +
{model()}
+
+
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} +
+
+
{info().title}
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ ) + })} +
+ )} +
+ ) +} -- cgit v1.2.3