summaryrefslogtreecommitdiffhomepage
path: root/packages/enterprise/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/enterprise/src')
-rw-r--r--packages/enterprise/src/app.css18
-rw-r--r--packages/enterprise/src/app.tsx28
-rw-r--r--packages/enterprise/src/core/share.ts139
-rw-r--r--packages/enterprise/src/core/storage.ts134
-rw-r--r--packages/enterprise/src/entry-client.tsx4
-rw-r--r--packages/enterprise/src/entry-server.tsx22
-rw-r--r--packages/enterprise/src/global.d.ts1
-rw-r--r--packages/enterprise/src/routes/[...404].tsx25
-rw-r--r--packages/enterprise/src/routes/api/[...path].ts152
-rw-r--r--packages/enterprise/src/routes/share.tsx5
-rw-r--r--packages/enterprise/src/routes/share/[sessionID].tsx172
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>
+ )
+}