diff options
| author | Frank <[email protected]> | 2025-09-18 10:59:01 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-09-18 10:59:01 -0400 |
| commit | 4ceabdffa07b1af8d99eb73622a4d549d99ec6d2 (patch) | |
| tree | 72e2ae62084a9e24cc76caffbd1f30dafc69ea56 /packages/console/app/src | |
| parent | c87480cf931a6f8f8b55552558ef521f1918b578 (diff) | |
| download | opencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.tar.gz opencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.zip | |
wip: zen
Diffstat (limited to 'packages/console/app/src')
61 files changed, 4452 insertions, 0 deletions
diff --git a/packages/console/app/src/app.css b/packages/console/app/src/app.css new file mode 100644 index 000000000..c0261c422 --- /dev/null +++ b/packages/console/app/src/app.css @@ -0,0 +1 @@ +@import "./style/index.css"; diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx new file mode 100644 index 000000000..bc3961214 --- /dev/null +++ b/packages/console/app/src/app.tsx @@ -0,0 +1,23 @@ +import { MetaProvider, Title, Meta } from "@solidjs/meta" +import { Router } from "@solidjs/router" +import { FileRoutes } from "@solidjs/start/router" +import { ErrorBoundary, Suspense } from "solid-js" +import "@ibm/plex/css/ibm-plex.css" +import "./app.css" + +export default function App() { + return ( + <Router + explicitLinks={true} + root={(props) => ( + <MetaProvider> + <Title>opencode</Title> + <Meta name="description" content="opencode - The AI coding agent built for the terminal." /> + <Suspense>{props.children}</Suspense> + </MetaProvider> + )} + > + <FileRoutes /> + </Router> + ) +} diff --git a/packages/console/app/src/asset/lander/check.svg b/packages/console/app/src/asset/lander/check.svg new file mode 100644 index 000000000..22de6f2a8 --- /dev/null +++ b/packages/console/app/src/asset/lander/check.svg @@ -0,0 +1,2 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"/></svg> + diff --git a/packages/console/app/src/asset/lander/copy.svg b/packages/console/app/src/asset/lander/copy.svg new file mode 100644 index 000000000..f1baac30a --- /dev/null +++ b/packages/console/app/src/asset/lander/copy.svg @@ -0,0 +1,2 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"/></svg> + diff --git a/packages/console/app/src/asset/lander/screenshot-github.png b/packages/console/app/src/asset/lander/screenshot-github.png Binary files differnew file mode 100644 index 000000000..fda74e641 --- /dev/null +++ b/packages/console/app/src/asset/lander/screenshot-github.png diff --git a/packages/console/app/src/asset/lander/screenshot-splash.png b/packages/console/app/src/asset/lander/screenshot-splash.png Binary files differnew file mode 100644 index 000000000..e900673ef --- /dev/null +++ b/packages/console/app/src/asset/lander/screenshot-splash.png diff --git a/packages/console/app/src/asset/lander/screenshot-vscode.png b/packages/console/app/src/asset/lander/screenshot-vscode.png Binary files differnew file mode 100644 index 000000000..b8966a6b8 --- /dev/null +++ b/packages/console/app/src/asset/lander/screenshot-vscode.png diff --git a/packages/console/app/src/asset/lander/screenshot.png b/packages/console/app/src/asset/lander/screenshot.png Binary files differnew file mode 100644 index 000000000..feb617585 --- /dev/null +++ b/packages/console/app/src/asset/lander/screenshot.png diff --git a/packages/console/app/src/asset/logo-ornate-dark.svg b/packages/console/app/src/asset/logo-ornate-dark.svg new file mode 100644 index 000000000..2efda934d --- /dev/null +++ b/packages/console/app/src/asset/logo-ornate-dark.svg @@ -0,0 +1,19 @@ +<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/> +<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/> +<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/> +<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/> +<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/> +<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/> +<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/> +<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/> +</svg> + diff --git a/packages/console/app/src/asset/logo-ornate-light.svg b/packages/console/app/src/asset/logo-ornate-light.svg new file mode 100644 index 000000000..789223bc4 --- /dev/null +++ b/packages/console/app/src/asset/logo-ornate-light.svg @@ -0,0 +1,18 @@ +<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/> +<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/> +<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/> +<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/> +<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/> +<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/> +<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/> +<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/> +</svg> diff --git a/packages/console/app/src/asset/logo.svg b/packages/console/app/src/asset/logo.svg new file mode 100644 index 000000000..cbfcccf51 --- /dev/null +++ b/packages/console/app/src/asset/logo.svg @@ -0,0 +1,12 @@ +<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/> +<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/> +<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/> +<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/> +<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/> +<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/> +<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/> +</svg> diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx new file mode 100644 index 000000000..a82572e62 --- /dev/null +++ b/packages/console/app/src/component/icon.tsx @@ -0,0 +1,82 @@ +import { JSX } from "solid-js" + +export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return ( + <svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" /> + <path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" /> + <path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" + fill="currentColor" + /> + <path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" /> + <path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" /> + <path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" + fill="currentColor" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" + fill="currentColor" + /> + <path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" /> + </svg> + ) +} + +export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return ( + <svg {...props} viewBox="0 0 512 512"> + <rect + width="336" + height="336" + x="128" + y="128" + fill="none" + stroke="currentColor" + stroke-linejoin="round" + stroke-width="32" + rx="57" + ry="57" + ></rect> + <path + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="32" + d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24" + ></path> + </svg> + ) +} + +export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return ( + <svg {...props} viewBox="0 0 24 24"> + <path + fill="currentColor" + d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z" + ></path> + </svg> + ) +} + +export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return ( + <svg {...props} viewBox="0 0 24 24"> + <path + fill="currentColor" + d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10z" + /> + </svg> + ) +} diff --git a/packages/console/app/src/component/workspace/billing-section.module.css b/packages/console/app/src/component/workspace/billing-section.module.css new file mode 100644 index 000000000..0bb5709cb --- /dev/null +++ b/packages/console/app/src/component/workspace/billing-section.module.css @@ -0,0 +1,114 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + [data-slot="reload-error"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + p { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + margin: 0; + flex: 1; + } + + [data-slot="create-form"] { + display: flex; + gap: var(--space-2); + margin: 0; + flex-shrink: 0; + } + } + [data-slot="payment"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + min-width: 14.5rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + + [data-slot="credit-card"] { + padding: var(--space-3-5) var(--space-4); + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: space-between; + + [data-slot="card-icon"] { + display: flex; + align-items: center; + color: var(--color-text-muted); + } + + [data-slot="card-details"] { + display: flex; + align-items: baseline; + gap: var(--space-1); + + [data-slot="secret"] { + position: relative; + bottom: 2px; + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: 400; + } + + [data-slot="number"] { + font-size: var(--font-size-3xl); + font-weight: 500; + color: var(--color-text); + } + } + } + + [data-slot="button-row"] { + display: flex; + gap: var(--space-2); + align-items: center; + + @media (max-width: 30rem) { + flex-direction: column; + + > button { + width: 100%; + } + } + + [data-slot="create-form"] { + margin: 0; + } + + /* Make Enable Billing button full width when it's the only button */ + > button { + flex: 1; + } + } + } + [data-slot="usage"] { + p { + font-size: var(--font-size-sm); + line-height: 1.5; + color: var(--color-text-secondary); + b { + font-weight: 600; + } + } + } +} diff --git a/packages/console/app/src/component/workspace/billing-section.tsx b/packages/console/app/src/component/workspace/billing-section.tsx new file mode 100644 index 000000000..57316e208 --- /dev/null +++ b/packages/console/app/src/component/workspace/billing-section.tsx @@ -0,0 +1,193 @@ +import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import { Billing } from "@opencode/console-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import { IconCreditCard } from "~/component/icon" +import styles from "./billing-section.module.css" + +const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { + "use server" + return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID) +}, "checkoutUrl") + +const reload = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key }) +}, "billing.reload") + +const disableReload = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key }) +}, "billing.disableReload") + +const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { + "use server" + return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID) +}, "sessionUrl") + +const getBillingInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.get() + }, workspaceID) +}, "billing.get") + +export function BillingSection() { + const params = useParams() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const createCheckoutUrlAction = useAction(createCheckoutUrl) + const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) + const createSessionUrlAction = useAction(createSessionUrl) + const createSessionUrlSubmission = useSubmission(createSessionUrl) + const disableReloadSubmission = useSubmission(disableReload) + const reloadSubmission = useSubmission(reload) + + // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW + + // Scenario 1: User has not added billing details and has no balance + // const balanceInfo = () => ({ + // balance: 0, + // paymentMethodLast4: null as string | null, + // reload: false, + // reloadError: null as string | null, + // timeReloadError: null as Date | null, + // }) + + // Scenario 2: User has not added billing details but has a balance + // const balanceInfo = () => ({ + // balance: 1500000000, // $15.00 + // paymentMethodLast4: null as string | null, + // reload: false, + // reloadError: null as string | null, + // timeReloadError: null as Date | null + // }) + + // Scenario 3: User has added billing details (reload enabled) + // const balanceInfo = () => ({ + // balance: 750000000, // $7.50 + // paymentMethodLast4: "4242", + // reload: true, + // reloadError: null as string | null, + // timeReloadError: null as Date | null + // }) + + // Scenario 4: User has billing details but reload failed + // const balanceInfo = () => ({ + // balance: 250000000, // $2.50 + // paymentMethodLast4: "4242", + // reload: true, + // reloadError: "Your card was declined." as string, + // timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago + // }) + + const balanceAmount = createMemo(() => { + return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2) + }) + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>Billing</h2> + <p> + Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions. + </p> + </div> + <div data-slot="section-content"> + <Show when={balanceInfo()?.reloadError}> + <div data-slot="reload-error"> + <p> + Reload failed at{" "} + {balanceInfo()?.timeReloadError!.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + })} + . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try + again. + </p> + <form action={reload} method="post" data-slot="create-form"> + <input type="hidden" name="workspaceID" value={params.id} /> + <button data-color="primary" type="submit" disabled={reloadSubmission.pending}> + {reloadSubmission.pending ? "Reloading..." : "Reload"} + </button> + </form> + </div> + </Show> + <div data-slot="payment"> + <div data-slot="credit-card"> + <div data-slot="card-icon"> + <IconCreditCard style={{ width: "32px", height: "32px" }} /> + </div> + <div data-slot="card-details"> + <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}> + <span data-slot="secret">••••</span> + <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span> + </Show> + </div> + </div> + <div data-slot="button-row"> + <Show + when={balanceInfo()?.reload} + fallback={ + <button + data-color="primary" + disabled={createCheckoutUrlSubmission.pending} + onClick={async () => { + const baseUrl = window.location.href + const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) + if (checkoutUrl) { + window.location.href = checkoutUrl + } + }} + > + {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} + </button> + } + > + <button + data-color="primary" + disabled={createSessionUrlSubmission.pending} + onClick={async () => { + const baseUrl = window.location.href + const sessionUrl = await createSessionUrlAction(params.id, baseUrl) + if (sessionUrl) { + window.location.href = sessionUrl + } + }} + > + {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"} + </button> + <form action={disableReload} method="post" data-slot="create-form"> + <input type="hidden" name="workspaceID" value={params.id} /> + <button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}> + {disableReloadSubmission.pending ? "Disabling..." : "Disable"} + </button> + </form> + </Show> + </div> + </div> + <div data-slot="usage"> + <Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}> + <p> + You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in + your account. You can continue using the API with your remaining balance. + </p> + </Show> + <Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}> + <p> + Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> + . We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>. + </p> + </Show> + </div> + </div> + </section> + ) +} diff --git a/packages/console/app/src/component/workspace/common.tsx b/packages/console/app/src/component/workspace/common.tsx new file mode 100644 index 000000000..f85fd8423 --- /dev/null +++ b/packages/console/app/src/component/workspace/common.tsx @@ -0,0 +1,25 @@ +export function formatDateForTable(date: Date) { + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + hour: "numeric", + minute: "2-digit", + hour12: true, + } + return date.toLocaleDateString("en-GB", options).replace(",", ",") +} + +export function formatDateUTC(date: Date) { + const options: Intl.DateTimeFormatOptions = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + timeZone: "UTC", + } + return date.toLocaleDateString("en-US", options) +} diff --git a/packages/console/app/src/component/workspace/key-section.module.css b/packages/console/app/src/component/workspace/key-section.module.css new file mode 100644 index 000000000..6a1d0c85f --- /dev/null +++ b/packages/console/app/src/component/workspace/key-section.module.css @@ -0,0 +1,172 @@ +.root { + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + display: flex; + flex-direction: column; + gap: var(--space-2); + + p { + line-height: 1.5; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + [data-slot="input-container"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + @media (max-width: 30rem) { + gap: var(--space-2); + } + + input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + margin-top: var(--space-1); + line-height: 1.4; + } + } + + [data-slot="api-keys-table"] { + overflow-x: auto; + } + + [data-slot="api-keys-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="key-name"] { + color: var(--color-text); + font-family: var(--font-sans); + font-weight: 500; + } + + &[data-slot="key-value"] { + font-family: var(--font-mono); + + button { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-sm); + font-weight: 400; + border: none; + background-color: transparent; + color: var(--color-text-muted); + font-family: var(--font-mono); + border-radius: var(--border-radius-sm); + cursor: pointer; + transition: all 0.15s ease; + text-transform: none; + + &:hover:not(:disabled) { + background-color: var(--color-bg-surface); + color: var(--color-text); + } + + &:disabled { + cursor: default; + color: var(--color-text); + } + + span { + font-family: inherit; + } + } + } + + &[data-slot="key-date"] { + color: var(--color-text); + } + + &[data-slot="key-actions"] { + font-family: var(--font-sans); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(3) /* Date */ { + display: none; + } + } + + td { + &:nth-child(3) /* Date */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/key-section.tsx b/packages/console/app/src/component/workspace/key-section.tsx new file mode 100644 index 000000000..a2bd380ea --- /dev/null +++ b/packages/console/app/src/component/workspace/key-section.tsx @@ -0,0 +1,182 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, createSignal, For, Show } from "solid-js" +import { IconCopy, IconCheck } from "~/component/icon" +import { Key } from "@opencode/console-core/key.js" +import { withActor } from "~/context/auth.withActor" +import { createStore } from "solid-js/store" +import { formatDateUTC, formatDateForTable } from "./common" +import styles from "./key-section.module.css" + +const removeKey = action(async (form: FormData) => { + "use server" + const id = form.get("id")?.toString() + if (!id) return { error: "ID is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key }) +}, "key.remove") + +const createKey = action(async (form: FormData) => { + "use server" + const name = form.get("name")?.toString().trim() + if (!name) return { error: "Name is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json( + await withActor( + () => + Key.create({ name }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listKeys.key }, + ) +}, "key.create") + +const listKeys = query(async (workspaceID: string) => { + "use server" + return withActor(() => Key.list(), workspaceID) +}, "key.list") + +export function KeyCreateForm() { + const params = useParams() + const submission = useSubmission(createKey) + const [store, setStore] = createStore({ show: false }) + + let input: HTMLInputElement + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + hide() + } + }) + + function show() { + // submission.clear() does not clear the result in some cases, ie. + // 1. Create key with empty name => error shows + // 2. Put in a key name and creates the key => form hides + // 3. Click add key button again => form shows with the same error if + // submission.clear() is called only once + while (true) { + submission.clear() + if (!submission.result) break + } + setStore("show", true) + input.focus() + } + + function hide() { + setStore("show", false) + } + + return ( + <Show + when={store.show} + fallback={ + <button data-color="primary" onClick={() => show()}> + Create API Key + </button> + } + > + <form action={createKey} method="post" data-slot="create-form"> + <div data-slot="input-container"> + <input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" /> + <Show when={submission.result && submission.result.error}> + {(err) => <div data-slot="form-error">{err()}</div>} + </Show> + </div> + <input type="hidden" name="workspaceID" value={params.id} /> + <div data-slot="form-actions"> + <button type="reset" data-color="ghost" onClick={() => hide()}> + Cancel + </button> + <button type="submit" data-color="primary" disabled={submission.pending}> + {submission.pending ? "Creating..." : "Create"} + </button> + </div> + </form> + </Show> + ) +} + +export function KeySection() { + const params = useParams() + const keys = createAsync(() => listKeys(params.id)) + + function formatKey(key: string) { + if (key.length <= 11) return key + return `${key.slice(0, 7)}...${key.slice(-4)}` + } + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>API Keys</h2> + <p>Manage your API keys for accessing opencode services.</p> + </div> + <KeyCreateForm /> + <div data-slot="api-keys-table"> + <Show + when={keys()?.length} + fallback={ + <div data-component="empty-state"> + <p>Create an opencode Gateway API key</p> + </div> + } + > + <table data-slot="api-keys-table-element"> + <thead> + <tr> + <th>Name</th> + <th>Key</th> + <th>Created</th> + <th></th> + </tr> + </thead> + <tbody> + <For each={keys()!}> + {(key) => { + const [copied, setCopied] = createSignal(false) + // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id) + return ( + <tr> + <td data-slot="key-name">{key.name}</td> + <td data-slot="key-value"> + <button + data-color="ghost" + disabled={copied()} + onClick={async () => { + await navigator.clipboard.writeText(key.key) + setCopied(true) + setTimeout(() => setCopied(false), 1000) + }} + title="Copy API key" + > + <span>{formatKey(key.key)}</span> + <Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}> + <IconCheck style={{ width: "14px", height: "14px" }} /> + </Show> + </button> + </td> + <td data-slot="key-date" title={formatDateUTC(key.timeCreated)}> + {formatDateForTable(key.timeCreated)} + </td> + <td data-slot="key-actions"> + <form action={removeKey} method="post"> + <input type="hidden" name="id" value={key.id} /> + <input type="hidden" name="workspaceID" value={params.id} /> + <button data-color="ghost">Delete</button> + </form> + </td> + </tr> + ) + }} + </For> + </tbody> + </table> + </Show> + </div> + </section> + ) +} diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.module.css b/packages/console/app/src/component/workspace/monthly-limit-section.module.css new file mode 100644 index 000000000..02de058e4 --- /dev/null +++ b/packages/console/app/src/component/workspace/monthly-limit-section.module.css @@ -0,0 +1,102 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + [data-slot="balance"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + min-width: 15rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + + [data-slot="amount"] { + padding: var(--space-3-5) var(--space-4); + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + display: flex; + align-items: baseline; + gap: var(--space-1); + justify-content: flex-end; + + [data-slot="currency"] { + position: relative; + bottom: 2px; + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: 400; + } + + [data-slot="value"] { + font-size: var(--font-size-3xl); + font-weight: 500; + color: var(--color-text); + } + } + + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-1); + + [data-slot="input-container"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + @media (max-width: 30rem) { + gap: var(--space-2); + } + + input { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + justify-content: flex-end; + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + } + } + + [data-slot="usage-status"] { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0; + line-height: 1.4; + } +} diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.tsx b/packages/console/app/src/component/workspace/monthly-limit-section.tsx new file mode 100644 index 000000000..35da774d0 --- /dev/null +++ b/packages/console/app/src/component/workspace/monthly-limit-section.tsx @@ -0,0 +1,139 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { withActor } from "~/context/auth.withActor" +import { Billing } from "@opencode/console-core/billing.js" +import styles from "./monthly-limit-section.module.css" + +const getBillingInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.get() + }, workspaceID) +}, "billing.get") + +const setMonthlyLimit = action(async (form: FormData) => { + "use server" + const limit = form.get("limit")?.toString() + if (!limit) return { error: "Limit is required." } + const numericLimit = parseInt(limit) + if (numericLimit < 0) return { error: "Set a valid monthly limit." } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required." } + return json( + await withActor( + () => + Billing.setMonthlyLimit(numericLimit) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: getBillingInfo.key }, + ) +}, "billing.setMonthlyLimit") + +export function MonthlyLimitSection() { + const params = useParams() + const submission = useSubmission(setMonthlyLimit) + const [store, setStore] = createStore({ show: false }) + const balanceInfo = createAsync(() => getBillingInfo(params.id)) + + let input: HTMLInputElement + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + hide() + } + }) + + function show() { + // submission.clear() does not clear the result in some cases, ie. + // 1. Create key with empty name => error shows + // 2. Put in a key name and creates the key => form hides + // 3. Click add key button again => form shows with the same error if + // submission.clear() is called only once + while (true) { + submission.clear() + if (!submission.result) break + } + setStore("show", true) + input.focus() + } + + function hide() { + setStore("show", false) + } + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>Monthly Limit</h2> + <p>Set a monthly spending limit for your account.</p> + </div> + <div data-slot="section-content"> + <div data-slot="balance"> + <div data-slot="amount"> + {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null} + <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span> + </div> + <Show + when={!store.show} + fallback={ + <form action={setMonthlyLimit} method="post" data-slot="create-form"> + <div data-slot="input-container"> + <input + required + ref={(r) => (input = r)} + data-component="input" + name="limit" + type="number" + placeholder="50" + /> + <Show when={submission.result && submission.result.error}> + {(err) => <div data-slot="form-error">{err()}</div>} + </Show> + </div> + <input type="hidden" name="workspaceID" value={params.id} /> + <div data-slot="form-actions"> + <button type="reset" data-color="ghost" onClick={() => hide()}> + Cancel + </button> + <button type="submit" data-color="primary" disabled={submission.pending}> + {submission.pending ? "Setting..." : "Set"} + </button> + </div> + </form> + } + > + <button data-color="primary" onClick={() => show()}> + {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"} + </button> + </Show> + </div> + <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}> + <p data-slot="usage-status"> + Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $ + {(() => { + const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated + if (!dateLastUsed) return "0" + + const current = new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + timeZone: "UTC", + }) + const lastUsed = dateLastUsed.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + timeZone: "UTC", + }) + if (current !== lastUsed) return "0" + return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) + })()} + . + </p> + </Show> + </div> + </section> + ) +} diff --git a/packages/console/app/src/component/workspace/new-user-section.module.css b/packages/console/app/src/component/workspace/new-user-section.module.css new file mode 100644 index 000000000..2edc7cc14 --- /dev/null +++ b/packages/console/app/src/component/workspace/new-user-section.module.css @@ -0,0 +1,163 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--space-8); + padding: var(--space-6); + background-color: var(--color-bg-surface); + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + + @media (max-width: 30rem) { + gap: var(--space-8); + padding: var(--space-4); + } + + [data-component="feature-grid"] { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-6); + + @media (max-width: 30rem) { + grid-template-columns: 1fr; + gap: var(--space-4); + } + + [data-slot="feature"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + h3 { + font-size: var(--font-size-sm); + font-weight: 600; + margin: 0; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: -0.025rem; + } + + p { + font-size: var(--font-size-sm); + line-height: 1.5; + margin: 0; + color: var(--color-text-muted); + } + } + } + + [data-component="api-key-highlight"] { + display: flex; + flex-direction: column; + gap: var(--space-6); + + [data-slot="section-title"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + + h2 { + font-size: var(--font-size-md); + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + color: var(--color-text-secondary); + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-md); + } + } + } + + [data-slot="key-display"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + + [data-slot="key-container"] { + display: flex; + gap: var(--space-3); + padding: var(--space-4); + border: 2px solid var(--color-accent); + border-radius: var(--border-radius-sm); + align-items: center; + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-3); + align-items: stretch; + } + + [data-slot="key-value"] { + flex: 1; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text); + background-color: var(--color-bg); + padding: var(--space-3); + border-radius: var(--border-radius-sm); + border: 1px solid var(--color-border); + word-break: break-all; + line-height: 1.4; + + @media (max-width: 40rem) { + font-size: var(--font-size-xs); + padding: var(--space-2-5); + } + } + + button { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + font-weight: 500; + white-space: nowrap; + min-width: 130px; + + @media (max-width: 40rem) { + justify-content: center; + padding: var(--space-2-5) var(--space-3); + font-size: var(--font-size-xs); + min-width: 96px; + } + } + } + } + } + + [data-component="next-steps"] { + display: flex; + flex-direction: column; + gap: var(--space-6); + + ol { + margin: 0; + padding-left: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + list-style-position: inside; + + li { + font-size: var(--font-size-md); + line-height: 1.5; + color: var(--color-text-secondary); + + code { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + color: var(--color-text); + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/new-user-section.tsx b/packages/console/app/src/component/workspace/new-user-section.tsx new file mode 100644 index 000000000..5909072dd --- /dev/null +++ b/packages/console/app/src/component/workspace/new-user-section.tsx @@ -0,0 +1,97 @@ +import { query, useParams, createAsync } from "@solidjs/router" +import { createMemo, createSignal, Show } from "solid-js" +import { IconCopy, IconCheck } from "~/component/icon" +import { Key } from "@opencode/console-core/key.js" +import { Billing } from "@opencode/console-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import styles from "./new-user-section.module.css" + +const getUsageInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.usages() + }, workspaceID) +}, "usage.list") + +const listKeys = query(async (workspaceID: string) => { + "use server" + return withActor(() => Key.list(), workspaceID) +}, "key.list") + +export function NewUserSection() { + const params = useParams() + const [copiedKey, setCopiedKey] = createSignal(false) + const keys = createAsync(() => listKeys(params.id)) + const usage = createAsync(() => getUsageInfo(params.id)) + const isNew = createMemo(() => { + const keysList = keys() + const usageList = usage() + return keysList?.length === 1 && (!usageList || usageList.length === 0) + }) + const defaultKey = createMemo(() => keys()?.at(-1)?.key) + + return ( + <Show when={isNew()}> + <div class={styles.root}> + <div data-component="feature-grid"> + <div data-slot="feature"> + <h3>Tested & Verified Models</h3> + <p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p> + </div> + <div data-slot="feature"> + <h3>Highest Quality</h3> + <p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p> + </div> + <div data-slot="feature"> + <h3>No Lock-in</h3> + <p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p> + </div> + </div> + + <div data-component="api-key-highlight"> + <Show when={defaultKey()}> + <div data-slot="key-display"> + <div data-slot="key-container"> + <code data-slot="key-value">{defaultKey()}</code> + <button + data-color="primary" + disabled={copiedKey()} + onClick={async () => { + await navigator.clipboard.writeText(defaultKey() ?? "") + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + }} + title="Copy API key" + > + <Show + when={copiedKey()} + fallback={ + <> + <IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key + </> + } + > + <IconCheck style={{ width: "16px", height: "16px" }} /> Copied! + </Show> + </button> + </div> + </div> + </Show> + </div> + + <div data-component="next-steps"> + <ol> + <li>Enable billing</li> + <li> + Run <code>opencode auth login</code> and select opencode + </li> + <li>Paste your API key</li> + <li> + Start opencode and run <code>/models</code> to select a model + </li> + </ol> + </div> + </div> + </Show> + ) +} diff --git a/packages/console/app/src/component/workspace/payment-section.module.css b/packages/console/app/src/component/workspace/payment-section.module.css new file mode 100644 index 000000000..ea8e2ed42 --- /dev/null +++ b/packages/console/app/src/component/workspace/payment-section.module.css @@ -0,0 +1,72 @@ +.root { + [data-slot="payments-table"] { + overflow-x: auto; + } + + [data-slot="payments-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="payment-date"] { + color: var(--color-text); + } + + &[data-slot="payment-id"] { + font-family: var(--font-mono); + font-weight: 400; + color: var(--color-text-muted); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="payment-amount"] { + color: var(--color-text); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(2) /* Payment ID */ { + display: none; + } + } + + td { + &:nth-child(2) /* Payment ID */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/payment-section.tsx b/packages/console/app/src/component/workspace/payment-section.tsx new file mode 100644 index 000000000..7be51a581 --- /dev/null +++ b/packages/console/app/src/component/workspace/payment-section.tsx @@ -0,0 +1,113 @@ +import { Billing } from "@opencode/console-core/billing.js" +import { query, action, useParams, createAsync, useAction } from "@solidjs/router" +import { For } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { formatDateUTC, formatDateForTable } from "./common" +import styles from "./payment-section.module.css" + +const getPaymentsInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.payments() + }, workspaceID) +}, "payment.list") + +const downloadReceipt = action(async (workspaceID: string, paymentID: string) => { + "use server" + return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID) +}, "receipt.download") + +export function PaymentSection() { + const params = useParams() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + const payments = createAsync(() => getPaymentsInfo(params.id)) + const downloadReceiptAction = useAction(downloadReceipt) + + // DUMMY DATA FOR TESTING + // const payments = () => [ + // { + // id: "pi_3QK1x2FT9vXn4A6r1234567890", + // paymentID: "pi_3QK1x2FT9vXn4A6r1234567890", + // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago + // amount: 2100000000, // $21.00 ($20 + $1 fee) + // }, + // { + // id: "pi_3QJ8k7FT9vXn4A6r0987654321", + // paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321", + // timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QI5m1FT9vXn4A6r5678901234", + // paymentID: "pi_3QI5m1FT9vXn4A6r5678901234", + // timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QH2n9FT9vXn4A6r3456789012", + // paymentID: "pi_3QH2n9FT9vXn4A6r3456789012", + // timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago + // amount: 2100000000, // $21.00 + // }, + // { + // id: "pi_3QG7p4FT9vXn4A6r7890123456", + // paymentID: "pi_3QG7p4FT9vXn4A6r7890123456", + // timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago + // amount: 2100000000, // $21.00 + // }, + // ] + + return ( + payments() && + payments()!.length > 0 && ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>Payments History</h2> + <p>Recent payment transactions.</p> + </div> + <div data-slot="payments-table"> + <table data-slot="payments-table-element"> + <thead> + <tr> + <th>Date</th> + <th>Payment ID</th> + <th>Amount</th> + <th>Receipt</th> + </tr> + </thead> + <tbody> + <For each={payments()!}> + {(payment) => { + const date = new Date(payment.timeCreated) + return ( + <tr> + <td data-slot="payment-date" title={formatDateUTC(date)}> + {formatDateForTable(date)} + </td> + <td data-slot="payment-id">{payment.id}</td> + <td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td> + <td data-slot="payment-receipt"> + <button + onClick={async () => { + const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!) + if (receiptUrl) { + window.open(receiptUrl, "_blank") + } + }} + data-slot="receipt-button" + style="cursor: pointer;" + > + view + </button> + </td> + </tr> + ) + }} + </For> + </tbody> + </table> + </div> + </section> + ) + ) +} diff --git a/packages/console/app/src/component/workspace/usage-section.module.css b/packages/console/app/src/component/workspace/usage-section.module.css new file mode 100644 index 000000000..1a772ba87 --- /dev/null +++ b/packages/console/app/src/component/workspace/usage-section.module.css @@ -0,0 +1,88 @@ +.root { + [data-component="empty-state"] { + padding: var(--space-20) var(--space-6); + text-align: center; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius-sm); + display: flex; + flex-direction: column; + gap: var(--space-2); + + p { + line-height: 1.5; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + [data-slot="usage-table"] { + overflow-x: auto; + } + + [data-slot="usage-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="usage-date"] { + color: var(--color-text); + } + + &[data-slot="usage-model"] { + font-family: var(--font-sans); + font-weight: 400; + color: var(--color-text-secondary); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="usage-cost"] { + color: var(--color-text); + } + } + + tbody tr { + &:last-child td { + border-bottom: none; + } + } + + @media (max-width: 40rem) { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + th { + &:nth-child(2) /* Model */ { + display: none; + } + } + + td { + &:nth-child(2) /* Model */ { + display: none; + } + } + } + } +} diff --git a/packages/console/app/src/component/workspace/usage-section.tsx b/packages/console/app/src/component/workspace/usage-section.tsx new file mode 100644 index 000000000..e68670c6d --- /dev/null +++ b/packages/console/app/src/component/workspace/usage-section.tsx @@ -0,0 +1,128 @@ +import { Billing } from "@opencode/console-core/billing.js" +import { query, useParams, createAsync } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import { formatDateUTC, formatDateForTable } from "./common" +import { withActor } from "~/context/auth.withActor" +import styles from "./usage-section.module.css" + +const getUsageInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.usages() + }, workspaceID) +}, "usage.list") + +export function UsageSection() { + const params = useParams() + // ORIGINAL CODE - COMMENTED OUT FOR TESTING + const usage = createAsync(() => getUsageInfo(params.id)) + + // DUMMY DATA FOR TESTING + // const usage = () => [ + // { + // timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 1247, + // outputTokens: 423, + // cost: 125400000, // $1.254 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago + // model: "claude-3-haiku-20240307", + // inputTokens: 892, + // outputTokens: 156, + // cost: 23500000, // $0.235 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 2134, + // outputTokens: 687, + // cost: 234700000, // $2.347 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago + // model: "gpt-4o-mini", + // inputTokens: 567, + // outputTokens: 234, + // cost: 8900000, // $0.089 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago + // model: "claude-3-opus-20240229", + // inputTokens: 1893, + // outputTokens: 945, + // cost: 445600000, // $4.456 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago + // model: "gpt-4o", + // inputTokens: 1456, + // outputTokens: 532, + // cost: 156800000, // $1.568 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago + // model: "claude-3-haiku-20240307", + // inputTokens: 634, + // outputTokens: 89, + // cost: 12300000, // $0.123 + // }, + // { + // timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago + // model: "claude-3-5-sonnet-20241022", + // inputTokens: 3245, + // outputTokens: 1123, + // cost: 387200000, // $3.872 + // }, + // ] + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>Usage History</h2> + <p>Recent API usage and costs.</p> + </div> + <div data-slot="usage-table"> + <Show + when={usage() && usage()!.length > 0} + fallback={ + <div data-component="empty-state"> + <p>Make your first API call to get started.</p> + </div> + } + > + <table data-slot="usage-table-element"> + <thead> + <tr> + <th>Date</th> + <th>Model</th> + <th>Input</th> + <th>Output</th> + <th>Cost</th> + </tr> + </thead> + <tbody> + <For each={usage()!}> + {(usage) => { + const date = createMemo(() => new Date(usage.timeCreated)) + return ( + <tr> + <td data-slot="usage-date" title={formatDateUTC(date())}> + {formatDateForTable(date())} + </td> + <td data-slot="usage-model">{usage.model}</td> + <td data-slot="usage-tokens">{usage.inputTokens}</td> + <td data-slot="usage-tokens">{usage.outputTokens}</td> + <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td> + </tr> + ) + }} + </For> + </tbody> + </table> + </Show> + </div> + </section> + ) +} diff --git a/packages/console/app/src/context/auth.session.ts b/packages/console/app/src/context/auth.session.ts new file mode 100644 index 000000000..609bc364b --- /dev/null +++ b/packages/console/app/src/context/auth.session.ts @@ -0,0 +1,23 @@ +import { useSession } from "vinxi/http" + +export interface AuthSession { + account?: Record< + string, + { + id: string + email: string + } + > + current?: string +} + +export function useAuthSession() { + return useSession<AuthSession>({ + password: "0".repeat(32), + name: "auth", + cookie: { + secure: false, + httpOnly: true, + }, + }) +} diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts new file mode 100644 index 000000000..027885241 --- /dev/null +++ b/packages/console/app/src/context/auth.ts @@ -0,0 +1,83 @@ +import { getRequestEvent } from "solid-js/web" +import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" +import { UserTable } from "@opencode/console-core/schema/user.sql.js" +import { redirect } from "@solidjs/router" +import { AccountTable } from "@opencode/console-core/schema/account.sql.js" +import { Actor } from "@opencode/console-core/actor.js" + +import { createClient } from "@openauthjs/openauth/client" +import { useAuthSession } from "./auth.session" + +export const AuthClient = createClient({ + clientID: "app", + issuer: import.meta.env.VITE_AUTH_URL, +}) + +export const getActor = async (workspace?: string): Promise<Actor.Info> => { + "use server" + const evt = getRequestEvent() + if (!evt) throw new Error("No request event") + if (evt.locals.actor) return evt.locals.actor + evt.locals.actor = (async () => { + const auth = await useAuthSession() + if (!workspace) { + const account = auth.data.account ?? {} + const current = account[auth.data.current ?? ""] + if (current) { + return { + type: "account", + properties: { + email: current.email, + accountID: current.id, + }, + } + } + if (Object.keys(account).length > 0) { + const current = Object.values(account)[0] + await auth.update((val) => ({ + ...val, + current: current.id, + })) + return { + type: "account", + properties: { + email: current.email, + accountID: current.id, + }, + } + } + return { + type: "public", + properties: {}, + } + } + const accounts = Object.keys(auth.data.account ?? {}) + if (accounts.length) { + const result = await Database.transaction(async (tx) => { + return await tx + .select({ + user: UserTable, + }) + .from(AccountTable) + .innerJoin(UserTable, and(eq(UserTable.email, AccountTable.email))) + .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) + .where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace))) + .limit(1) + .execute() + .then((x) => x[0]) + }) + if (result) { + return { + type: "user", + properties: { + userID: result.user.id, + workspaceID: result.user.workspaceID, + }, + } + } + } + throw redirect("/auth/authorize") + })() + return evt.locals.actor +} diff --git a/packages/console/app/src/context/auth.withActor.ts b/packages/console/app/src/context/auth.withActor.ts new file mode 100644 index 000000000..2cb970269 --- /dev/null +++ b/packages/console/app/src/context/auth.withActor.ts @@ -0,0 +1,7 @@ +import { Actor } from "@opencode/console-core/actor.js" +import { getActor } from "./auth" + +export async function withActor<T>(fn: () => T, workspace?: string) { + const actor = await getActor(workspace) + return Actor.provide(actor.type, actor.properties, fn) +} diff --git a/packages/console/app/src/entry-client.tsx b/packages/console/app/src/entry-client.tsx new file mode 100644 index 000000000..642deacf7 --- /dev/null +++ b/packages/console/app/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/console/app/src/entry-server.tsx b/packages/console/app/src/entry-server.tsx new file mode 100644 index 000000000..d5fca6aa5 --- /dev/null +++ b/packages/console/app/src/entry-server.tsx @@ -0,0 +1,28 @@ +// @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.svg" /> + <meta property="og:image" content="/social-share.png" /> + <meta property="twitter:image" content="/social-share.png" /> + {assets} + </head> + <body> + <div id="app">{children}</div> + {scripts} + </body> + </html> + )} + /> + ), + { + mode: "async", + }, +) diff --git a/packages/console/app/src/global.d.ts b/packages/console/app/src/global.d.ts new file mode 100644 index 000000000..dc6f10c22 --- /dev/null +++ b/packages/console/app/src/global.d.ts @@ -0,0 +1 @@ +/// <reference types="@solidjs/start/env" /> diff --git a/packages/console/app/src/middleware.ts b/packages/console/app/src/middleware.ts new file mode 100644 index 000000000..b49473cbe --- /dev/null +++ b/packages/console/app/src/middleware.ts @@ -0,0 +1,5 @@ +import { defineMiddleware } from "vinxi/http" + +export default defineMiddleware({ + onBeforeResponse() {}, +}) diff --git a/packages/console/app/src/routes/[...404].css b/packages/console/app/src/routes/[...404].css new file mode 100644 index 000000000..1edbd0a5a --- /dev/null +++ b/packages/console/app/src/routes/[...404].css @@ -0,0 +1,130 @@ +[data-page="not-found"] { + --color-text: hsl(224, 10%, 10%); + --color-text-secondary: hsl(224, 7%, 46%); + --color-text-dimmed: hsl(224, 6%, 63%); + --color-text-inverted: hsl(0, 0%, 100%); + + --color-border: hsl(224, 6%, 77%); +} + +[data-page="not-found"] { + @media (prefers-color-scheme: dark) { + --color-text: hsl(0, 0%, 100%); + --color-text-secondary: hsl(224, 6%, 66%); + --color-text-dimmed: hsl(224, 7%, 46%); + --color-text-inverted: hsl(224, 10%, 10%); + + --color-border: hsl(224, 6%, 36%); + } +} + +[data-page="not-found"] { + --padding: 3rem; + --vertical-padding: 1.5rem; + --heading-font-size: 1.375rem; + + @media (max-width: 30rem) { + --padding: 1rem; + --vertical-padding: 0.75rem; + --heading-font-size: 1rem; + } + + font-family: var(--font-mono); + color: var(--color-text); + padding: calc(var(--padding) + 1rem); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + + a { + color: var(--color-text); + text-decoration: underline; + text-underline-offset: var(--space-0-75); + text-decoration-thickness: 1px; + } + + [data-component="content"] { + max-width: 40rem; + width: 100%; + border: 1px solid var(--color-border); + } + + [data-component="top"] { + padding: var(--padding); + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--vertical-padding) / 2); + text-align: center; + + [data-slot="logo-link"] { + text-decoration: none; + } + + img { + height: auto; + width: clamp(200px, 85vw, 400px); + } + + [data-slot="logo dark"] { + display: none; + } + + @media (prefers-color-scheme: dark) { + [data-slot="logo light"] { + display: none; + } + [data-slot="logo dark"] { + display: block; + } + } + + [data-slot="title"] { + line-height: 1.25; + font-weight: 500; + text-align: center; + font-size: var(--heading-font-size); + color: var(--color-text); + text-transform: uppercase; + margin: 0; + } + } + + [data-component="actions"] { + border-top: 1px solid var(--color-border); + display: flex; + + [data-slot="action"] { + flex: 1; + text-align: center; + line-height: 1.4; + padding: var(--vertical-padding) 1rem; + text-transform: uppercase; + font-size: 1rem; + + a { + display: block; + width: 100%; + height: 100%; + color: var(--color-text); + text-decoration: underline; + text-underline-offset: var(--space-0-75); + text-decoration-thickness: 1px; + } + } + + [data-slot="action"] + [data-slot="action"] { + border-left: 1px solid var(--color-border); + } + + @media (max-width: 40rem) { + flex-direction: column; + + [data-slot="action"] + [data-slot="action"] { + border-left: none; + border-top: 1px solid var(--color-border); + } + } + } +} diff --git a/packages/console/app/src/routes/[...404].tsx b/packages/console/app/src/routes/[...404].tsx new file mode 100644 index 000000000..ba2842b5a --- /dev/null +++ b/packages/console/app/src/routes/[...404].tsx @@ -0,0 +1,38 @@ +import "./[...404].css" +import { Title } from "@solidjs/meta" +import { HttpStatusCode } from "@solidjs/start" +import logoLight from "../asset/logo-ornate-light.svg" +import logoDark from "../asset/logo-ornate-dark.svg" + +export default function NotFound() { + return ( + <main data-page="not-found"> + <Title>Not Found | opencode</Title> + <HttpStatusCode code={404} /> + <div data-component="content"> + <section data-component="top"> + <a href="/" data-slot="logo-link"> + <img data-slot="logo light" src={logoLight} alt="opencode logo light" /> + <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" /> + </a> + <h1 data-slot="title">404 - Page Not Found</h1> + </section> + + <section data-component="actions"> + <div data-slot="action"> + <a href="/">Home</a> + </div> + <div data-slot="action"> + <a href="/docs">Docs</a> + </div> + <div data-slot="action"> + <a href="https://github.com/sst/opencode">GitHub</a> + </div> + <div data-slot="action"> + <a href="/discord">Discord</a> + </div> + </section> + </div> + </main> + ) +} diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts new file mode 100644 index 000000000..166466ef8 --- /dev/null +++ b/packages/console/app/src/routes/auth/authorize.ts @@ -0,0 +1,7 @@ +import type { APIEvent } from "@solidjs/start/server" +import { AuthClient } from "~/context/auth" + +export async function GET(input: APIEvent) { + const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code") + return Response.redirect(result.url, 302) +} diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts new file mode 100644 index 000000000..23025b54d --- /dev/null +++ b/packages/console/app/src/routes/auth/callback.ts @@ -0,0 +1,31 @@ +import { redirect } from "@solidjs/router" +import type { APIEvent } from "@solidjs/start/server" +import { AuthClient } from "~/context/auth" +import { useAuthSession } from "~/context/auth.session" + +export async function GET(input: APIEvent) { + const url = new URL(input.request.url) + const code = url.searchParams.get("code") + if (!code) throw new Error("No code found") + const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) + if (result.err) { + throw new Error(result.err.message) + } + const decoded = AuthClient.decode(result.tokens.access, {} as any) + if (decoded.err) throw new Error(decoded.err.message) + const session = await useAuthSession() + const id = decoded.subject.properties.accountID + await session.update((value) => { + return { + ...value, + account: { + [id]: { + id, + email: decoded.subject.properties.email, + }, + }, + current: id, + } + }) + return redirect("/auth") +} diff --git a/packages/console/app/src/routes/auth/index.ts b/packages/console/app/src/routes/auth/index.ts new file mode 100644 index 000000000..2c893185f --- /dev/null +++ b/packages/console/app/src/routes/auth/index.ts @@ -0,0 +1,13 @@ +import { Account } from "@opencode/console-core/account.js" +import { redirect } from "@solidjs/router" +import type { APIEvent } from "@solidjs/start/server" +import { withActor } from "~/context/auth.withActor" + +export async function GET(input: APIEvent) { + try { + const workspaces = await withActor(async () => Account.workspaces()) + return redirect(`/workspace/${workspaces[0].id}`) + } catch { + return redirect("/auth/authorize") + } +} diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts new file mode 100644 index 000000000..39fa33d90 --- /dev/null +++ b/packages/console/app/src/routes/debug/index.ts @@ -0,0 +1,13 @@ +import type { APIEvent } from "@solidjs/start/server" +import { json } from "@solidjs/router" +import { Database } from "@opencode/console-core/drizzle/index.js" +import { UserTable } from "@opencode/console-core/schema/user.sql.js" + +export async function GET(evt: APIEvent) { + return json({ + data: await Database.use(async (tx) => { + const result = await tx.$count(UserTable) + return result + }), + }) +} diff --git a/packages/console/app/src/routes/discord.ts b/packages/console/app/src/routes/discord.ts new file mode 100644 index 000000000..7088295da --- /dev/null +++ b/packages/console/app/src/routes/discord.ts @@ -0,0 +1,5 @@ +import { redirect } from "@solidjs/router" + +export async function GET() { + return redirect("https://discord.gg/opencode") +} diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts new file mode 100644 index 000000000..f07781583 --- /dev/null +++ b/packages/console/app/src/routes/docs/[...path].ts @@ -0,0 +1,20 @@ +import type { APIEvent } from "@solidjs/start/server" + +async function handler(evt: APIEvent) { + const req = evt.request.clone() + const url = new URL(req.url) + const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}` + const response = await fetch(targetUrl, { + method: req.method, + headers: req.headers, + body: req.body, + }) + return response +} + +export const GET = handler +export const POST = handler +export const PUT = handler +export const DELETE = handler +export const OPTIONS = handler +export const PATCH = handler diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts new file mode 100644 index 000000000..f07781583 --- /dev/null +++ b/packages/console/app/src/routes/docs/index.ts @@ -0,0 +1,20 @@ +import type { APIEvent } from "@solidjs/start/server" + +async function handler(evt: APIEvent) { + const req = evt.request.clone() + const url = new URL(req.url) + const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}` + const response = await fetch(targetUrl, { + method: req.method, + headers: req.headers, + body: req.body, + }) + return response +} + +export const GET = handler +export const POST = handler +export const PUT = handler +export const DELETE = handler +export const OPTIONS = handler +export const PATCH = handler diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css new file mode 100644 index 000000000..fe95bb7ea --- /dev/null +++ b/packages/console/app/src/routes/index.css @@ -0,0 +1,504 @@ +[data-page="home"] { + --color-text: hsl(224, 10%, 10%); + --color-text-secondary: hsl(224, 7%, 46%); + --color-text-dimmed: hsl(224, 6%, 63%); + --color-text-inverted: hsl(0, 0%, 100%); + + --color-border: hsl(224, 6%, 77%); +} + +[data-page="home"] { + @media (prefers-color-scheme: dark) { + --color-text: hsl(0, 0%, 100%); + --color-text-secondary: hsl(224, 6%, 66%); + --color-text-dimmed: hsl(224, 7%, 46%); + --color-text-inverted: hsl(224, 10%, 10%); + + --color-border: hsl(224, 6%, 36%); + } +} + +[data-page="home"] { + --padding: 3rem; + --vertical-padding: 1.5rem; + --heading-font-size: 1.375rem; + + @media (max-width: 30rem) { + --padding: 1rem; + --vertical-padding: 0.75rem; + --heading-font-size: 1rem; + } + + display: flex; + gap: var(--vertical-padding); + flex-direction: column; + font-family: var(--font-mono); + color: var(--color-text); + padding: calc(var(--padding) + 1rem); + + a { + color: var(--color-text); + text-decoration: underline; + text-underline-offset: var(--space-0-75); + text-decoration-thickness: 1px; + } + + [data-component="content"] { + max-width: 67.5rem; + margin: 0 auto; + border: 1px solid var(--color-border); + } + + [data-component="top"] { + padding: calc(var(--padding) * 1.5) var(--padding) var(--padding); + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--vertical-padding) / 2); + + img { + height: auto; + width: clamp(200px, 85vw, 552px); + } + + [data-slot="logo dark"] { + display: none; + } + + @media (prefers-color-scheme: dark) { + [data-slot="logo light"] { + display: none; + } + [data-slot="logo dark"] { + display: block; + } + } + + [data-slot="title"] { + line-height: 1.25; + font-weight: 500; + text-align: center; + font-size: var(--heading-font-size); + color: var(--color-text-secondary); + text-transform: uppercase; + } + + [data-slot="login"] { + position: absolute; + top: 0; + right: 0; + border-width: 0 0 1px 1px; + border-style: solid; + border-color: var(--color-border); + background-color: var(--color-bg); + + @media (max-width: 30rem) { + display: none; + } + + a { + display: block; + padding: 0.5rem 1rem calc(0.5rem + 4px); + } + } + } + + [data-component="cta"] { + border-top: 1px solid var(--color-border); + display: flex; + + & > div + div { + border-left: 1px solid var(--color-border); + } + + [data-slot="left"] { + flex: 0 0 auto; + text-align: center; + line-height: 1.4; + padding: var(--vertical-padding) 2rem; + text-transform: uppercase; + font-size: 1.125rem; + + @media (max-width: 30rem) { + font-size: 1rem; + padding-bottom: calc(var(--vertical-padding) + 4px); + } + + @media (max-width: 30rem) { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + } + + [data-slot="center"] { + display: none; + + @media (max-width: 30rem) { + display: block; + flex: 1; + text-align: center; + padding: var(--vertical-padding) 0.5rem; + border-top: 1px solid var(--color-border); + border-left: none; + } + } + + [data-slot="right"] { + flex: 1; + padding: var(--vertical-padding) 1rem; + } + + @media (max-width: 50rem) { + flex-direction: column; + + [data-slot="right"] { + border-left: none; + border-top: 1px solid var(--color-border); + } + } + + [data-slot="command"] { + all: unset; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--color-text-secondary); + font-size: 1.125rem; + font-family: var(--font-mono); + gap: var(--space-2); + width: 100%; + + & > span { + @media (max-width: 24rem) { + font-size: 0.875rem; + } + @media (max-width: 56rem) { + [data-slot="protocol"] { + display: none; + } + } + @media (max-width: 38rem) { + text-align: center; + span:first-child { + display: block; + } + } + } + } + + [data-slot="highlight"] { + color: var(--color-text); + font-weight: 500; + } + } + + [data-component="features"] { + border-top: 1px solid var(--color-border); + padding: var(--padding); + + [data-slot="list"] { + padding-left: var(--space-4); + margin: 0; + list-style: disc; + + li { + margin-bottom: var(--space-4); + line-height: 1.6; + + strong { + text-transform: uppercase; + font-weight: 600; + } + + label { + line-height: 1; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.03125rem; + background: var(--color-border); + padding: 0.125rem 0.375rem; + color: var(--color-text-inverted); + } + } + + li:last-child { + margin-bottom: 0; + } + } + } + + [data-component="install"] { + border-top: 1px solid var(--color-border); + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + + @media (max-width: 40rem) { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + } + + [data-component="method"] { + display: flex; + padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem); + flex-direction: column; + text-align: left; + gap: var(--space-2-5); + + @media (max-width: 30rem) { + gap: 0.3125rem; + } + + @media (max-width: 40rem) { + text-align: left; + } + + &:nth-child(2) { + border-left: 1px solid var(--color-border); + + @media (max-width: 40rem) { + border-left: none; + border-top: 1px solid var(--color-border); + } + } + + &:nth-child(3) { + border-top: 1px solid var(--color-border); + } + + &:nth-child(4) { + border-top: 1px solid var(--color-border); + border-left: 1px solid var(--color-border); + + @media (max-width: 40rem) { + border-left: none; + } + } + + [data-component="title"] { + letter-spacing: -0.03125rem; + text-transform: uppercase; + font-weight: normal; + font-size: 1rem; + flex-shrink: 0; + color: var(--color-text-dimmed); + + @media (max-width: 30rem) { + font-size: 0.75rem; + } + } + + [data-slot="button"] { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + color: var(--color-text-secondary); + gap: var(--space-2-5); + font-size: 1rem; + + @media (max-width: 24rem) { + font-size: 0.875rem; + } + + strong { + color: var(--color-text); + font-weight: 500; + } + + @media (max-width: 40rem) { + justify-content: flex-start; + } + + @media (max-width: 30rem) { + justify-content: center; + } + } + } + + [data-component="screenshots"] { + border-top: 1px solid var(--color-border); + + figure { + flex: 1; + display: flex; + flex-direction: column; + gap: calc(var(--padding) / 4); + padding: calc(var(--padding) / 2); + border-width: 0; + border-style: solid; + border-color: var(--color-border); + min-height: 0; + overflow: hidden; + + & > div, + figcaption { + display: flex; + align-items: center; + } + + & > div { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + } + + a { + display: flex; + flex: 1; + min-height: 0; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + figcaption { + letter-spacing: -0.03125rem; + text-transform: uppercase; + color: var(--color-text-dimmed); + flex-shrink: 0; + + @media (max-width: 30rem) { + font-size: 0.75rem; + } + } + } + + & > [data-slot="left"] figure { + height: var(--images-height); + box-sizing: border-box; + } + + & > [data-slot="right"] figure { + height: calc(var(--images-height) / 2); + box-sizing: border-box; + } + + & > [data-slot="left"] img { + width: 100%; + height: 100%; + min-width: 0; + object-fit: contain; + } + + & > [data-slot="right"] img { + width: 100%; + height: calc(100% - 2rem); + object-fit: contain; + display: block; + } + + @media (max-width: 30rem) { + & { + --images-height: auto; + grid-template-columns: 1fr; + grid-template-rows: auto auto; + } + + & > [data-slot="left"] { + grid-row: 1; + grid-column: 1; + } + + & > [data-slot="right"] { + grid-row: 2; + grid-column: 1; + border-left: none; + border-top: 1px solid var(--color-border); + + & > [data-slot="row1"], + & > [data-slot="row2"] { + height: auto; + } + } + + & > [data-slot="left"] figure, + & > [data-slot="right"] figure { + height: auto; + } + + & > [data-slot="left"] img, + & > [data-slot="right"] img { + width: 100%; + height: auto; + max-height: none; + } + } + } + + [data-component="copy-status"] { + @media (max-width: 38rem) { + display: none; + } + + [data-slot="copy"] { + display: block; + width: var(--space-4); + height: var(--space-4); + color: var(--color-text-dimmed); + + [data-copied] & { + display: none; + } + } + + [data-slot="check"] { + display: none; + width: var(--space-4); + height: var(--space-4); + color: var(--color-text); + + [data-copied] & { + display: block; + } + } + } + + [data-component="footer"] { + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: row; + + [data-slot="cell"] { + flex: 1; + text-align: center; + text-transform: uppercase; + padding: var(--vertical-padding) 0.5rem; + } + + [data-slot="cell"] + [data-slot="cell"] { + border-left: 1px solid var(--color-border); + } + + /* Mobile: third column on its own row */ + @media (max-width: 30rem) { + flex-wrap: wrap; + + [data-slot="cell"]:nth-child(1), + [data-slot="cell"]:nth-child(2) { + flex: 1; + } + + [data-slot="cell"]:nth-child(3) { + flex: 1 0 100%; + border-left: none; + border-top: 1px solid var(--color-border); + } + } + } + + [data-component="legal"] { + color: var(--color-text-dimmed); + text-align: center; + + a { + color: var(--color-text-dimmed); + } + } +} diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx new file mode 100644 index 000000000..e8c1998ae --- /dev/null +++ b/packages/console/app/src/routes/index.tsx @@ -0,0 +1,183 @@ +import "./index.css" +import { Title } from "@solidjs/meta" +import { onCleanup, onMount } from "solid-js" +import logoLight from "../asset/logo-ornate-light.svg" +import logoDark from "../asset/logo-ornate-dark.svg" +import IMG_SPLASH from "../asset/lander/screenshot-splash.png" +import { IconCopy, IconCheck } from "../component/icon" +import { createAsync, query } from "@solidjs/router" +import { getActor } from "~/context/auth" +import { withActor } from "~/context/auth.withActor" +import { Account } from "@opencode/console-core/account.js" + +function CopyStatus() { + return ( + <div data-component="copy-status"> + <IconCopy data-slot="copy" /> + <IconCheck data-slot="check" /> + </div> + ) +} + +const defaultWorkspace = query(async () => { + "use server" + const actor = await getActor() + if (actor.type === "account") { + const workspaces = await withActor(() => Account.workspaces()) + return workspaces[0].id + } +}, "defaultWorkspace") + +export default function Home() { + const workspace = createAsync(() => defaultWorkspace()) + onMount(() => { + const commands = document.querySelectorAll("[data-copy]") + for (const button of commands) { + const callback = () => { + const text = button.textContent + if (text) { + navigator.clipboard.writeText(text) + button.setAttribute("data-copied", "") + setTimeout(() => { + button.removeAttribute("data-copied") + }, 1500) + } + } + button.addEventListener("click", callback) + onCleanup(() => { + button.removeEventListener("click", callback) + }) + } + }) + + return ( + <main data-page="home"> + <Title>opencode | AI coding agent built for the terminal</Title> + + <div data-component="content"> + <section data-component="top"> + <img data-slot="logo light" src={logoLight} alt="opencode logo light" /> + <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" /> + <h1 data-slot="title">The AI coding agent built for the terminal</h1> + <div data-slot="login"> + <a href="/auth">opencode zen</a> + </div> + </section> + + <section data-component="cta"> + <div data-slot="left"> + <a href="/docs">Get Started</a> + </div> + <div data-slot="center"> + <a href="/auth">opencode zen</a> + </div> + <div data-slot="right"> + <button data-copy data-slot="command"> + <span> + <span>curl -fsSL </span> + <span data-slot="protocol">https://</span> + <span data-slot="highlight">opencode.ai/install</span> + <span> | bash</span> + </span> + <CopyStatus /> + </button> + </div> + </section> + + <section data-component="features"> + <ul data-slot="list"> + <li> + <strong>Native TUI</strong> A responsive, native, themeable terminal UI + </li> + <li> + <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM + </li> + <li> + <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "} + <label>New</label> + </li> + <li> + <strong>Multi-session</strong> Start multiple agents in parallel on the same project + </li> + <li> + <strong>Shareable links</strong> Share a link to any sessions for reference or to debug + </li> + <li> + <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account + </li> + <li> + <strong>Use any model</strong> Supports 75+ LLM providers through{" "} + <a href="https://models.dev">Models.dev</a>, including local models + </li> + </ul> + </section> + + <section data-component="install"> + <div data-component="method"> + <h3 data-component="title">npm</h3> + <button data-copy data-slot="button"> + <span> + npm install -g <strong>opencode-ai</strong> + </span> + <CopyStatus /> + </button> + </div> + <div data-component="method"> + <h3 data-component="title">bun</h3> + <button data-copy data-slot="button"> + <span> + bun install -g <strong>opencode-ai</strong> + </span> + <CopyStatus /> + </button> + </div> + <div data-component="method"> + <h3 data-component="title">homebrew</h3> + <button data-copy data-slot="button"> + <span> + brew install <strong>sst/tap/opencode</strong> + </span> + <CopyStatus /> + </button> + </div> + <div data-component="method"> + <h3 data-component="title">paru</h3> + <button data-copy data-slot="button"> + <span> + paru -S <strong>opencode-bin</strong> + </span> + <CopyStatus /> + </button> + </div> + </section> + + <section data-component="screenshots"> + <figure> + <figcaption>opencode TUI with the tokyonight theme</figcaption> + <a href="/docs/cli"> + <img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" /> + </a> + </figure> + </section> + + <footer data-component="footer"> + <div data-slot="cell"> + <a href="https://x.com/opencode">X.com</a> + </div> + <div data-slot="cell"> + <a href="https://github.com/sst/opencode">GitHub</a> + </div> + <div data-slot="cell"> + <a href="https://opencode.ai/discord">Discord</a> + </div> + </footer> + </div> + + <div data-component="legal"> + <span> + ©2025 <a href="https://anoma.ly">Anomaly Innovations</a> + </span> + </div> + </main> + ) +} diff --git a/packages/console/app/src/routes/s/[id].ts b/packages/console/app/src/routes/s/[id].ts new file mode 100644 index 000000000..3fd1305a0 --- /dev/null +++ b/packages/console/app/src/routes/s/[id].ts @@ -0,0 +1,20 @@ +import type { APIEvent } from "@solidjs/start/server" + +async function handler(evt: APIEvent) { + const req = evt.request.clone() + const url = new URL(req.url) + const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}` + const response = await fetch(targetUrl, { + method: req.method, + headers: req.headers, + body: req.body, + }) + return response +} + +export const GET = handler +export const POST = handler +export const PUT = handler +export const DELETE = handler +export const OPTIONS = handler +export const PATCH = handler diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts new file mode 100644 index 000000000..920966286 --- /dev/null +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -0,0 +1,98 @@ +import { Billing } from "@opencode/console-core/billing.js" +import type { APIEvent } from "@solidjs/start/server" +import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js" +import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js" +import { Identifier } from "@opencode/console-core/identifier.js" +import { centsToMicroCents } from "@opencode/console-core/util/price.js" +import { Actor } from "@opencode/console-core/actor.js" +import { Resource } from "@opencode/console-resource" + +export async function POST(input: APIEvent) { + const body = await Billing.stripe().webhooks.constructEventAsync( + await input.request.text(), + input.request.headers.get("stripe-signature")!, + Resource.STRIPE_WEBHOOK_SECRET.value, + ) + + console.log(body.type, JSON.stringify(body, null, 2)) + if (body.type === "customer.updated") { + // check default payment method changed + const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {} + if (!("default_payment_method" in prevInvoiceSettings)) return + + const customerID = body.data.object.id + const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string + + if (!customerID) throw new Error("Customer ID not found") + if (!paymentMethodID) throw new Error("Payment method ID not found") + + const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) + await Database.use(async (tx) => { + await tx + .update(BillingTable) + .set({ + paymentMethodID, + paymentMethodLast4: paymentMethod.card!.last4, + }) + .where(eq(BillingTable.customerID, customerID)) + }) + } + if (body.type === "checkout.session.completed") { + const workspaceID = body.data.object.metadata?.workspaceID + const customerID = body.data.object.customer as string + const paymentID = body.data.object.payment_intent as string + const amount = body.data.object.amount_total + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!customerID) throw new Error("Customer ID not found") + if (!amount) throw new Error("Amount not found") + if (!paymentID) throw new Error("Payment ID not found") + + await Actor.provide("system", { workspaceID }, async () => { + const customer = await Billing.get() + if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") + + // set customer metadata + if (!customer?.customerID) { + await Billing.stripe().customers.update(customerID, { + metadata: { + workspaceID, + }, + }) + } + + // get payment method for the payment intent + const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { + expand: ["payment_method"], + }) + const paymentMethod = paymentIntent.payment_method + if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded") + + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`, + customerID, + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card!.last4, + reload: true, + reloadError: null, + timeReloadError: null, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + await tx.insert(PaymentTable).values({ + workspaceID, + id: Identifier.create("payment"), + amount: centsToMicroCents(Billing.CHARGE_AMOUNT), + paymentID, + customerID, + }) + }) + }) + } + + console.log("finished handling") + + return Response.json("ok", { status: 200 }) +} diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css new file mode 100644 index 000000000..ed94365f0 --- /dev/null +++ b/packages/console/app/src/routes/workspace.css @@ -0,0 +1,127 @@ +[data-page="workspace"] { + line-height: 1; + + /* Common elements */ + button { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-sans); + font-weight: 500; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + background-color: var(--color-surface-hover); + border-color: var(--color-accent); + } + + &:active { + transform: translateY(1px); + } + + &:disabled { + opacity: 0.5; + transform: none; + } + + &[data-color="primary"] { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-primary-text); + + &:hover:not(:disabled) { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); + } + } + + &[data-color="ghost"] { + background-color: transparent; + border-color: transparent; + color: var(--color-text-muted); + + &:hover:not(:disabled) { + background-color: var(--color-surface-hover); + border-color: var(--color-border); + color: var(--color-text); + } + } + } + + a { + color: var(--color-text); + text-decoration: underline; + text-underline-offset: var(--space-0-75); + text-decoration-thickness: 1px; + } + + /* Workspace Header */ + [data-component="workspace-header"] { + position: sticky; + top: 0; + z-index: 100; + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-4) var(--space-4); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg); + + @media (max-width: 30rem) { + padding: var(--space-4) var(--space-4); + } + } + + [data-slot="header-brand"] { + flex: 0 0 auto; + padding-top: 4px; + + svg { + width: 138px; + } + + [data-component="site-title"] { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text); + text-decoration: none; + letter-spacing: -0.02em; + } + } + + [data-slot="header-actions"] { + display: flex; + gap: var(--space-4); + align-items: center; + font-size: var(--font-size-sm); + + [data-slot="user"] { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + [data-slot="user"] { + display: none; + } + } + + a, + button { + appearance: none; + background: none; + border: none; + cursor: pointer; + padding: 0; + color: var(--color-text); + text-decoration: underline; + text-underline-offset: var(--space-0-75); + text-decoration-thickness: 1px; + text-transform: uppercase; + } + } +} diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx new file mode 100644 index 000000000..3aa3f20d3 --- /dev/null +++ b/packages/console/app/src/routes/workspace.tsx @@ -0,0 +1,67 @@ +import "./workspace.css" +import { useAuthSession } from "~/context/auth.session" +import { IconLogo } from "../component/icon" +import { withActor } from "~/context/auth.withActor" +import { + query, + action, + redirect, + createAsync, + RouteSectionProps, + Navigate, + useNavigate, + useParams, + A, +} from "@solidjs/router" +import { User } from "@opencode/console-core/user.js" +import { Actor } from "@opencode/console-core/actor.js" +import { getRequestEvent } from "solid-js/web" + +const getUserInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + const actor = Actor.assert("user") + return await User.fromID(actor.properties.userID) + }, workspaceID) +}, "userInfo") + +const logout = action(async () => { + "use server" + const auth = await useAuthSession() + const event = getRequestEvent() + const current = auth.data.current + if (current) + await auth.update((val) => { + delete val.account?.[current] + const first = Object.keys(val.account ?? {})[0] + val.current = first + event!.locals.actor = undefined + return val + }) + throw redirect("/") +}) + +export default function WorkspaceLayout(props: RouteSectionProps) { + const params = useParams() + const userInfo = createAsync(() => getUserInfo(params.id)) + return ( + <main data-page="workspace"> + <header data-component="workspace-header"> + <div data-slot="header-brand"> + <A href="/" data-component="site-title"> + <IconLogo /> + </A> + </div> + <div data-slot="header-actions"> + <span data-slot="user">{userInfo()?.email}</span> + <form action={logout} method="post"> + <button type="submit" formaction={logout}> + Logout + </button> + </form> + </div> + </header> + <div>{props.children}</div> + </main> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css new file mode 100644 index 000000000..8b318a19f --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id].css @@ -0,0 +1,115 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [data-slot="section-title"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + + h2 { + font-size: var(--font-size-md); + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + color: var(--color-text-secondary); + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-md); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx new file mode 100644 index 000000000..68a706d5d --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -0,0 +1,50 @@ +import "./[id].css" +import { Billing } from "@opencode/console-core/billing.js" +import { query, useParams, createAsync } from "@solidjs/router" +import { Show } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section" +import { NewUserSection } from "~/component/workspace/new-user-section" +import { BillingSection } from "~/component/workspace/billing-section" +import { PaymentSection } from "~/component/workspace/payment-section" +import { UsageSection } from "~/component/workspace/usage-section" +import { KeySection } from "~/component/workspace/key-section" + +const getBillingInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return await Billing.get() + }, workspaceID) +}, "billing.get") + +export default function () { + const params = useParams() + const balanceInfo = createAsync(() => getBillingInfo(params.id)) + + return ( + <div data-page="workspace-[id]"> + <section data-component="title-section"> + <h1>Zen</h1> + <p> + Curated list of models provided by opencode.{" "} + <a target="_blank" href="/docs/zen"> + Learn more + </a> + . + </p> + </section> + + <div data-slot="sections"> + <NewUserSection /> + <KeySection /> + <BillingSection /> + <Show when={true}> + {/*<Show when={balanceInfo()?.reload}>*/} + <MonthlyLimitSection /> + </Show> + <UsageSection /> + <PaymentSection /> + </div> + </div> + ) +} diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/packages/console/app/src/routes/workspace/index.tsx diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts new file mode 100644 index 000000000..6065e2f76 --- /dev/null +++ b/packages/console/app/src/routes/zen/handler.ts @@ -0,0 +1,594 @@ +import type { APIEvent } from "@solidjs/start/server" +import path from "node:path" +import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js" +import { KeyTable } from "@opencode/console-core/schema/key.sql.js" +import { BillingTable, PaymentTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js" +import { centsToMicroCents } from "@opencode/console-core/util/price.js" +import { Identifier } from "@opencode/console-core/identifier.js" +import { Resource } from "@opencode/console-resource" +import { Billing } from "../../../../core/src/billing" +import { Actor } from "@opencode/console-core/actor.js" + +type ModelCost = { + input: number + output: number + cacheRead?: number + cacheWrite5m?: number + cacheWrite1h?: number +} + +type Model = { + id: string + auth: boolean + cost: ModelCost | ((usage: any) => ModelCost) + headerMappings: Record<string, string> + providers: Record< + string, + { + api: string + apiKey: string + model: string + weight?: number + } + > +} + +export async function handler( + input: APIEvent, + opts: { + modifyBody?: (body: any) => any + setAuthHeader: (headers: Headers, apiKey: string) => void + parseApiKey: (headers: Headers) => string | undefined + onStreamPart: (chunk: string) => void + getStreamUsage: () => any + normalizeUsage: (body: any) => { + inputTokens: number + outputTokens: number + reasoningTokens?: number + cacheReadTokens?: number + cacheWrite5mTokens?: number + cacheWrite1hTokens?: number + } + }, +) { + class AuthError extends Error {} + class CreditsError extends Error {} + class MonthlyLimitError extends Error {} + class ModelError extends Error {} + + const MODELS: Record<string, Model> = { + "claude-opus-4-1": { + id: "claude-opus-4-1" as const, + auth: true, + cost: { + input: 0.000015, + output: 0.000075, + cacheRead: 0.0000015, + cacheWrite5m: 0.00001875, + cacheWrite1h: 0.00003, + }, + headerMappings: {}, + providers: { + anthropic: { + api: "https://api.anthropic.com", + apiKey: Resource.ANTHROPIC_API_KEY.value, + model: "claude-opus-4-1-20250805", + }, + }, + }, + "claude-sonnet-4": { + id: "claude-sonnet-4" as const, + auth: true, + cost: (usage: any) => { + const totalInputTokens = + usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens + return totalInputTokens <= 200_000 + ? { + input: 0.000003, + output: 0.000015, + cacheRead: 0.0000003, + cacheWrite5m: 0.00000375, + cacheWrite1h: 0.000006, + } + : { + input: 0.000006, + output: 0.0000225, + cacheRead: 0.0000006, + cacheWrite5m: 0.0000075, + cacheWrite1h: 0.000012, + } + }, + headerMappings: {}, + providers: { + anthropic: { + api: "https://api.anthropic.com", + apiKey: Resource.ANTHROPIC_API_KEY.value, + model: "claude-sonnet-4-20250514", + }, + }, + }, + "claude-3-5-haiku": { + id: "claude-3-5-haiku" as const, + auth: true, + cost: { + input: 0.0000008, + output: 0.000004, + cacheRead: 0.00000008, + cacheWrite5m: 0.000001, + cacheWrite1h: 0.0000016, + }, + headerMappings: {}, + providers: { + anthropic: { + api: "https://api.anthropic.com", + apiKey: Resource.ANTHROPIC_API_KEY.value, + model: "claude-3-5-haiku-20241022", + }, + }, + }, + "gpt-5": { + id: "gpt-5" as const, + auth: true, + cost: { + input: 0.00000125, + output: 0.00001, + cacheRead: 0.000000125, + }, + headerMappings: {}, + providers: { + openai: { + api: "https://api.openai.com", + apiKey: Resource.OPENAI_API_KEY.value, + model: "gpt-5", + }, + }, + }, + "qwen3-coder": { + id: "qwen3-coder" as const, + auth: true, + cost: { + input: 0.00000045, + output: 0.0000018, + }, + headerMappings: {}, + providers: { + baseten: { + api: "https://inference.baseten.co", + apiKey: Resource.BASETEN_API_KEY.value, + model: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + weight: 4, + }, + fireworks: { + api: "https://api.fireworks.ai/inference", + apiKey: Resource.FIREWORKS_API_KEY.value, + model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", + weight: 1, + }, + }, + }, + "kimi-k2": { + id: "kimi-k2" as const, + auth: true, + cost: { + input: 0.0000006, + output: 0.0000025, + }, + headerMappings: {}, + providers: { + baseten: { + api: "https://inference.baseten.co", + apiKey: Resource.BASETEN_API_KEY.value, + model: "moonshotai/Kimi-K2-Instruct-0905", + //weight: 4, + }, + //fireworks: { + // api: "https://api.fireworks.ai/inference", + // apiKey: Resource.FIREWORKS_API_KEY.value, + // model: "accounts/fireworks/models/kimi-k2-instruct-0905", + // weight: 1, + //}, + }, + }, + "grok-code": { + id: "grok-code" as const, + auth: false, + cost: { + input: 0, + output: 0, + cacheRead: 0, + }, + headerMappings: { + "x-grok-conv-id": "x-opencode-session", + "x-grok-req-id": "x-opencode-request", + }, + providers: { + xai: { + api: "https://api.x.ai", + apiKey: Resource.XAI_API_KEY.value, + model: "grok-code", + }, + }, + }, + // deprecated + "qwen/qwen3-coder": { + id: "qwen/qwen3-coder" as const, + auth: true, + cost: { + input: 0.00000038, + output: 0.00000153, + }, + headerMappings: {}, + providers: { + baseten: { + api: "https://inference.baseten.co", + apiKey: Resource.BASETEN_API_KEY.value, + model: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + weight: 5, + }, + fireworks: { + api: "https://api.fireworks.ai/inference", + apiKey: Resource.FIREWORKS_API_KEY.value, + model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", + weight: 1, + }, + }, + }, + } + + const FREE_WORKSPACES = [ + "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank + ] + + const logger = { + metric: (values: Record<string, any>) => { + console.log(`_metric:${JSON.stringify(values)}`) + }, + log: console.log, + debug: (message: string) => { + if (Resource.App.stage === "production") return + console.debug(message) + }, + } + + try { + const url = new URL(input.request.url) + const body = await input.request.json() + logger.debug(JSON.stringify(body)) + logger.metric({ + is_tream: !!body.stream, + session: input.request.headers.get("x-opencode-session"), + request: input.request.headers.get("x-opencode-request"), + }) + const MODEL = validateModel() + const apiKey = await authenticate() + const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "") + await checkCreditsAndLimit() + const providerName = selectProvider() + const providerData = MODEL.providers[providerName] + logger.metric({ provider: providerName }) + + // Request to model provider + const startTimestamp = Date.now() + const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), { + method: "POST", + headers: (() => { + const headers = input.request.headers + headers.delete("host") + headers.delete("content-length") + opts.setAuthHeader(headers, providerData.apiKey) + Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => { + headers.set(k, headers.get(v)!) + }) + return headers + })(), + body: JSON.stringify({ + ...(opts.modifyBody?.(body) ?? body), + model: providerData.model, + }), + }) + + // Scrub response headers + const resHeaders = new Headers() + const keepHeaders = ["content-type", "cache-control"] + for (const [k, v] of res.headers.entries()) { + if (keepHeaders.includes(k.toLowerCase())) { + resHeaders.set(k, v) + } + } + + // Handle non-streaming response + if (!body.stream) { + const json = await res.json() + const body = JSON.stringify(json) + logger.metric({ response_length: body.length }) + logger.debug(body) + await trackUsage(json.usage) + await reload() + return new Response(body, { + status: res.status, + statusText: res.statusText, + headers: resHeaders, + }) + } + + // Handle streaming response + const stream = new ReadableStream({ + start(c) { + const reader = res.body?.getReader() + const decoder = new TextDecoder() + let buffer = "" + let responseLength = 0 + + function pump(): Promise<void> { + return ( + reader?.read().then(async ({ done, value }) => { + if (done) { + logger.metric({ response_length: responseLength }) + const usage = opts.getStreamUsage() + if (usage) { + await trackUsage(usage) + await reload() + } + c.close() + return + } + + if (responseLength === 0) { + logger.metric({ time_to_first_byte: Date.now() - startTimestamp }) + } + responseLength += value.length + buffer += decoder.decode(value, { stream: true }) + + const parts = buffer.split("\n\n") + buffer = parts.pop() ?? "" + + for (const part of parts) { + logger.debug(part) + opts.onStreamPart(part.trim()) + } + + c.enqueue(value) + + return pump() + }) || Promise.resolve() + ) + } + + return pump() + }, + }) + + return new Response(stream, { + status: res.status, + statusText: res.statusText, + headers: resHeaders, + }) + + function validateModel() { + if (!(body.model in MODELS)) { + throw new ModelError(`Model ${body.model} not supported`) + } + const model = MODELS[body.model as keyof typeof MODELS] + logger.metric({ model: model.id }) + return model + } + + async function authenticate() { + try { + const apiKey = opts.parseApiKey(input.request.headers) + if (!apiKey) throw new AuthError("Missing API key.") + + const key = await Database.use((tx) => + tx + .select({ + id: KeyTable.id, + workspaceID: KeyTable.workspaceID, + }) + .from(KeyTable) + .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) + .then((rows) => rows[0]), + ) + + if (!key) throw new AuthError("Invalid API key.") + logger.metric({ + api_key: key.id, + workspace: key.workspaceID, + }) + return key + } catch (e) { + // ignore error if model does not require authentication + if (!MODEL.auth) return + throw e + } + } + + async function checkCreditsAndLimit() { + if (!apiKey || !MODEL.auth || isFree) return + + const billing = await Database.use((tx) => + tx + .select({ + balance: BillingTable.balance, + paymentMethodID: BillingTable.paymentMethodID, + monthlyLimit: BillingTable.monthlyLimit, + monthlyUsage: BillingTable.monthlyUsage, + timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, apiKey.workspaceID)) + .then((rows) => rows[0]), + ) + + if (!billing.paymentMethodID) throw new CreditsError("No payment method") + if (billing.balance <= 0) throw new CreditsError("Insufficient balance") + if ( + billing.monthlyLimit && + billing.monthlyUsage && + billing.timeMonthlyUsageUpdated && + billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) + ) { + const now = new Date() + const currentYear = now.getUTCFullYear() + const currentMonth = now.getUTCMonth() + const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear() + const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth() + if (currentYear === dateYear && currentMonth === dateMonth) + throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`) + } + } + + function selectProvider() { + const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) => + Array<string>(provider.weight ?? 1).fill(name), + ) + return picks[Math.floor(Math.random() * picks.length)] + } + + async function trackUsage(usage: any) { + const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = + opts.normalizeUsage(usage) + + const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost + + const inputCost = modelCost.input * inputTokens * 100 + const outputCost = modelCost.output * outputTokens * 100 + const reasoningCost = (() => { + if (!reasoningTokens) return undefined + return modelCost.output * reasoningTokens * 100 + })() + const cacheReadCost = (() => { + if (!cacheReadTokens) return undefined + if (!modelCost.cacheRead) return undefined + return modelCost.cacheRead * cacheReadTokens * 100 + })() + const cacheWrite5mCost = (() => { + if (!cacheWrite5mTokens) return undefined + if (!modelCost.cacheWrite5m) return undefined + return modelCost.cacheWrite5m * cacheWrite5mTokens * 100 + })() + const cacheWrite1hCost = (() => { + if (!cacheWrite1hTokens) return undefined + if (!modelCost.cacheWrite1h) return undefined + return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 + })() + const totalCostInCent = + inputCost + + outputCost + + (reasoningCost ?? 0) + + (cacheReadCost ?? 0) + + (cacheWrite5mCost ?? 0) + + (cacheWrite1hCost ?? 0) + + logger.metric({ + "tokens.input": inputTokens, + "tokens.output": outputTokens, + "tokens.reasoning": reasoningTokens, + "tokens.cache_read": cacheReadTokens, + "tokens.cache_write_5m": cacheWrite5mTokens, + "tokens.cache_write_1h": cacheWrite1hTokens, + "cost.input": Math.round(inputCost), + "cost.output": Math.round(outputCost), + "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, + "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, + "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, + "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, + "cost.total": Math.round(totalCostInCent), + }) + + if (!apiKey) return + + const cost = isFree ? 0 : centsToMicroCents(totalCostInCent) + await Database.transaction(async (tx) => { + await tx.insert(UsageTable).values({ + workspaceID: apiKey.workspaceID, + id: Identifier.create("usage"), + model: MODEL.id, + provider: providerName, + inputTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWrite5mTokens, + cacheWrite1hTokens, + cost, + }) + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${cost}`, + monthlyUsage: sql` + CASE + WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost} + ELSE ${cost} + END + `, + timeMonthlyUsageUpdated: sql`now()`, + }) + .where(eq(BillingTable.workspaceID, apiKey.workspaceID)) + }) + + await Database.use((tx) => + tx + .update(KeyTable) + .set({ timeUsed: sql`now()` }) + .where(eq(KeyTable.id, apiKey.id)), + ) + } + + async function reload() { + if (!apiKey) return + + const lock = await Database.use((tx) => + tx + .update(BillingTable) + .set({ + timeReloadLockedTill: sql`now() + interval 1 minute`, + }) + .where( + and( + eq(BillingTable.workspaceID, apiKey.workspaceID), + eq(BillingTable.reload, true), + lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)), + or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), + ), + ), + ) + if (lock.rowsAffected === 0) return + + await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => { + await Billing.reload() + }) + } + } catch (error: any) { + logger.metric({ + "error.type": error.constructor.name, + "error.message": error.message, + }) + + // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. + if ( + error instanceof AuthError || + error instanceof CreditsError || + error instanceof MonthlyLimitError || + error instanceof ModelError + ) + return new Response( + JSON.stringify({ + type: "error", + error: { type: error.constructor.name, message: error.message }, + }), + { status: 401 }, + ) + + return new Response( + JSON.stringify({ + type: "error", + error: { + type: "error", + message: error.message, + }, + }), + { status: 500 }, + ) + } +} diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts new file mode 100644 index 000000000..801557324 --- /dev/null +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -0,0 +1,54 @@ +import type { APIEvent } from "@solidjs/start/server" +import { handler } from "~/routes/zen/handler" + +type Usage = { + prompt_tokens?: number + completion_tokens?: number + total_tokens?: number + prompt_tokens_details?: { + text_tokens?: number + audio_tokens?: number + image_tokens?: number + cached_tokens?: number + } + completion_tokens_details?: { + reasoning_tokens?: number + audio_tokens?: number + accepted_prediction_tokens?: number + rejected_prediction_tokens?: number + } +} + +export function POST(input: APIEvent) { + let usage: Usage + return handler(input, { + modifyBody: (body: any) => ({ + ...body, + ...(body.stream ? { stream_options: { include_usage: true } } : {}), + }), + setAuthHeader: (headers: Headers, apiKey: string) => { + headers.set("authorization", `Bearer ${apiKey}`) + }, + parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], + onStreamPart: (chunk: string) => { + if (!chunk.startsWith("data: ")) return + + let json + try { + json = JSON.parse(chunk.slice(6)) as { usage?: Usage } + } catch (e) { + return + } + + if (!json.usage) return + usage = json.usage + }, + getStreamUsage: () => usage, + normalizeUsage: (usage: Usage) => ({ + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined, + cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined, + }), + }) +} diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts new file mode 100644 index 000000000..1fd85d5c7 --- /dev/null +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -0,0 +1,61 @@ +import type { APIEvent } from "@solidjs/start/server" +import { handler } from "~/routes/zen/handler" + +type Usage = { + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + input_tokens?: number + output_tokens?: number + server_tool_use?: { + web_search_requests?: number + } +} + +export function POST(input: APIEvent) { + let usage: Usage + return handler(input, { + modifyBody: (body: any) => ({ + ...body, + service_tier: "standard_only", + }), + setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey), + parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, + onStreamPart: (chunk: string) => { + const data = chunk.split("\n")[1] + if (!data.startsWith("data: ")) return + + let json + try { + json = JSON.parse(data.slice(6)) as { usage?: Usage } + } catch (e) { + return + } + + if (!json.usage) return + usage = { + ...usage, + ...json.usage, + cache_creation: { + ...usage?.cache_creation, + ...json.usage.cache_creation, + }, + server_tool_use: { + ...usage?.server_tool_use, + ...json.usage.server_tool_use, + }, + } + }, + getStreamUsage: () => usage, + normalizeUsage: (usage: Usage) => ({ + inputTokens: usage.input_tokens ?? 0, + outputTokens: usage.output_tokens ?? 0, + cacheReadTokens: usage.cache_read_input_tokens ?? undefined, + cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined, + cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined, + }), + }) +} diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts new file mode 100644 index 000000000..486c129b9 --- /dev/null +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -0,0 +1,52 @@ +import type { APIEvent } from "@solidjs/start/server" +import { handler } from "~/routes/zen/handler" + +type Usage = { + input_tokens?: number + input_tokens_details?: { + cached_tokens?: number + } + output_tokens?: number + output_tokens_details?: { + reasoning_tokens?: number + } + total_tokens?: number +} + +export function POST(input: APIEvent) { + let usage: Usage + return handler(input, { + setAuthHeader: (headers: Headers, apiKey: string) => { + headers.set("authorization", `Bearer ${apiKey}`) + }, + parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], + onStreamPart: (chunk: string) => { + const [event, data] = chunk.split("\n") + if (event !== "event: response.completed") return + if (!data.startsWith("data: ")) return + + let json + try { + json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } } + } catch (e) { + return + } + + if (!json.response?.usage) return + usage = json.response.usage + }, + getStreamUsage: () => usage, + normalizeUsage: (usage: Usage) => { + const inputTokens = usage.input_tokens ?? 0 + const outputTokens = usage.output_tokens ?? 0 + const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined + const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined + return { + inputTokens: inputTokens - (cacheReadTokens ?? 0), + outputTokens: outputTokens - (reasoningTokens ?? 0), + reasoningTokens, + cacheReadTokens, + } + }, + }) +} diff --git a/packages/console/app/src/style/base.css b/packages/console/app/src/style/base.css new file mode 100644 index 000000000..a4847ed43 --- /dev/null +++ b/packages/console/app/src/style/base.css @@ -0,0 +1,9 @@ +html { + line-height: 1; + background-color: var(--color-bg); + color: var(--color-text); +} + +body { + font-family: var(--font-sans); +} diff --git a/packages/console/app/src/style/component/button.css b/packages/console/app/src/style/component/button.css new file mode 100644 index 000000000..d10f7af53 --- /dev/null +++ b/packages/console/app/src/style/component/button.css @@ -0,0 +1,102 @@ +[data-component="button"] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid transparent; + border-radius: var(--space-2); + font-family: var(--font-sans); + font-size: var(--font-size-md); + font-weight: 500; + line-height: 1.25; + cursor: pointer; + transition: all 0.2s ease-in-out; + text-decoration: none; + user-select: none; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary); + } + + &[data-color="primary"] { + background-color: var(--color-primary); + color: var(--color-primary-text); + border-color: var(--color-primary); + + &:hover:not(:disabled) { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); + } + + &:active:not(:disabled) { + background-color: var(--color-primary-active); + border-color: var(--color-primary-active); + } + } + + &[data-color="danger"] { + background-color: var(--color-danger); + color: var(--color-danger-text); + border-color: var(--color-danger); + + &:hover:not(:disabled) { + background-color: var(--color-danger-hover); + border-color: var(--color-danger-hover); + } + + &:active:not(:disabled) { + background-color: var(--color-danger-active); + border-color: var(--color-danger-active); + } + + &:focus { + box-shadow: 0 0 0 2px var(--color-danger); + } + } + + &[data-color="warning"] { + background-color: var(--color-warning); + color: var(--color-warning-text); + border-color: var(--color-warning); + + &:hover:not(:disabled) { + background-color: var(--color-warning-hover); + border-color: var(--color-warning-hover); + } + + &:active:not(:disabled) { + background-color: var(--color-warning-active); + border-color: var(--color-warning-active); + } + + &:focus { + box-shadow: 0 0 0 2px var(--color-warning); + } + } + + &[data-size="small"] { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-sm); + gap: var(--space-1-5); + } + + &[data-size="large"] { + padding: var(--space-4) var(--space-6); + font-size: var(--font-size-lg); + gap: var(--space-3); + } + + [data-slot="icon"] { + display: flex; + align-items: center; + width: 1em; + height: 1em; + } +} diff --git a/packages/console/app/src/style/index.css b/packages/console/app/src/style/index.css new file mode 100644 index 000000000..832a901e8 --- /dev/null +++ b/packages/console/app/src/style/index.css @@ -0,0 +1,8 @@ +@import "./token/color.css"; +@import "./token/font.css"; +@import "./token/space.css"; + +@import "./component/button.css"; + +@import "./reset.css"; +@import "./base.css"; diff --git a/packages/console/app/src/style/reset.css b/packages/console/app/src/style/reset.css new file mode 100644 index 000000000..d331ed724 --- /dev/null +++ b/packages/console/app/src/style/reset.css @@ -0,0 +1,76 @@ +/* 1. Use a more-intuitive box-sizing model */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* 2. Remove default margin */ +* { + margin: 0; +} + +/* 3. Enable keyword animations */ +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +body { + /* 4. Add accessible line-height */ + line-height: 1.5; + /* 5. Improve text rendering */ + -webkit-font-smoothing: antialiased; +} + +/* 6. Improve media defaults */ +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +/* 7. Inherit fonts for form controls */ +input, +button, +textarea, +select { + font: inherit; +} + +/* 8. Avoid text overflows */ +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +/* 9. Improve line wrapping */ +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; +} + +/* + 10. Create a root stacking context +*/ +#root, +#__next { + isolation: isolate; +} diff --git a/packages/console/app/src/style/token/color.css b/packages/console/app/src/style/token/color.css new file mode 100644 index 000000000..f1a097d2f --- /dev/null +++ b/packages/console/app/src/style/token/color.css @@ -0,0 +1,91 @@ +:root { + --color-white: #ffffff; + --color-black: #000000; + + /* Default light theme colors */ + --color-bg: #ffffff; + --color-bg-surface: #f5f5f7; + --color-bg-elevated: #ffffff; + + --color-text: #1d1d1f; + --color-text-secondary: #424245; + --color-text-muted: #6e6e73; + --color-text-disabled: #86868b; + + --color-accent: #007aff; + --color-accent-hover: #0056b3; + --color-accent-active: #004085; + + --color-success: #30d158; + --color-warning: #ff9f0a; + --color-danger: #ff3b30; + + --color-border: #d2d2d7; + --color-border-muted: #e5e5ea; + + /* Button colors */ + --color-primary: var(--color-accent); + --color-primary-hover: var(--color-accent-hover); + --color-primary-active: var(--color-accent-active); + --color-primary-text: #ffffff; + + --color-danger: #ff3b30; + --color-danger-hover: #d70015; + --color-danger-active: #a50011; + --color-danger-text: #ffffff; + + --color-warning: #ff9f0a; + --color-warning-hover: #cc7f08; + --color-warning-active: #995f06; + --color-warning-text: #000000; + + /* Surface colors */ + --color-surface: var(--color-bg-surface); + --color-surface-hover: var(--color-bg-elevated); + --color-surface-border: var(--color-border); +} + +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #0c0c0e; + --color-bg-surface: #161618; + --color-bg-elevated: #1c1c1f; + + --color-text: #ffffff; + --color-text-secondary: #c7c7cc; + --color-text-muted: #a1a1a6; + --color-text-disabled: #68686f; + + --color-accent: #007aff; + --color-accent-hover: #0056b3; + --color-accent-active: #004085; + + --color-success: #30d158; + --color-warning: #ff9f0a; + --color-danger: #ff453a; + + --color-border: #38383a; + --color-border-muted: #2c2c2e; + + /* Button colors */ + --color-primary: var(--color-accent); + --color-primary-hover: var(--color-accent-hover); + --color-primary-active: var(--color-accent-active); + --color-primary-text: #ffffff; + + --color-danger: #ff453a; + --color-danger-hover: #d70015; + --color-danger-active: #a50011; + --color-danger-text: #ffffff; + + --color-warning: #ff9f0a; + --color-warning-hover: #cc7f08; + --color-warning-active: #995f06; + --color-warning-text: #000000; + + /* Surface colors */ + --color-surface: var(--color-bg-surface); + --color-surface-hover: var(--color-bg-elevated); + --color-surface-border: var(--color-border); + } +} diff --git a/packages/console/app/src/style/token/font.css b/packages/console/app/src/style/token/font.css new file mode 100644 index 000000000..67143e662 --- /dev/null +++ b/packages/console/app/src/style/token/font.css @@ -0,0 +1,20 @@ +body { + --font-size-2xs: 0.6875rem; + --font-size-xs: 0.75rem; + --font-size-sm: 0.8125rem; + --font-size-md: 0.9375rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + --font-size-4xl: 2.25rem; + --font-size-5xl: 3rem; + --font-size-6xl: 3.75rem; + --font-size-7xl: 4.5rem; + --font-size-8xl: 6rem; + --font-size-9xl: 8rem; + + --font-mono: + "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --font-sans: var(--font-mono); +} diff --git a/packages/console/app/src/style/token/space.css b/packages/console/app/src/style/token/space.css new file mode 100644 index 000000000..7e1a1b397 --- /dev/null +++ b/packages/console/app/src/style/token/space.css @@ -0,0 +1,46 @@ +body { + --space-0: 0; + --space-px: 1px; + --space-0-5: 0.125rem; + --space-0-75: 0.1875rem; + --space-1: 0.25rem; + --space-1-5: 0.375rem; + --space-2: 0.5rem; + --space-2-5: 0.625rem; + --space-3: 0.75rem; + --space-3-5: 0.875rem; + --space-4: 1rem; + --space-4-5: 1.125rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-7: 1.75rem; + --space-8: 2rem; + --space-9: 2.25rem; + --space-10: 2.5rem; + --space-11: 2.75rem; + --space-12: 3rem; + --space-14: 3.5rem; + --space-16: 4rem; + --space-17: 4.25rem; + --space-18: 4.5rem; + --space-19: 4.75rem; + --space-20: 5rem; + --space-24: 6rem; + --space-28: 7rem; + --space-32: 8rem; + --space-36: 9rem; + --space-40: 10rem; + --space-44: 11rem; + --space-48: 12rem; + --space-52: 13rem; + --space-56: 14rem; + --space-60: 15rem; + --space-64: 16rem; + --space-72: 18rem; + --space-80: 20rem; + --space-96: 24rem; + + --border-radius-sm: 0.1875rem; + --border-radius-md: 0.3125rem; + --border-radius-lg: 0.5rem; +} |
