summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-08 13:31:12 -0400
committerFrank <[email protected]>2025-10-08 13:31:15 -0400
commitb168bfe40df1ac9c3185766cdcaed688572c1a8a (patch)
treeec736534cc5fc3ab8e43207a6ddc6d62b1a7a927 /packages/console/app/src
parent1d621260ff83751e70859c3f4c6a834bb481ed81 (diff)
downloadopencode-b168bfe40df1ac9c3185766cdcaed688572c1a8a.tar.gz
opencode-b168bfe40df1ac9c3185766cdcaed688572c1a8a.zip
wip: zen
Diffstat (limited to 'packages/console/app/src')
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx2
-rw-r--r--packages/console/app/src/routes/workspace/provider-section.module.css107
-rw-r--r--packages/console/app/src/routes/workspace/provider-section.tsx163
3 files changed, 272 insertions, 0 deletions
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index 952c1417d..a44ddd927 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -8,6 +8,7 @@ import { KeySection } from "./key-section"
import { MemberSection } from "./member-section"
import { SettingsSection } from "./settings-section"
import { ModelSection } from "./model-section"
+import { ProviderSection } from "./provider-section"
import { Show } from "solid-js"
import { createAsync, query, useParams } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -52,6 +53,7 @@ export default function () {
<SettingsSection />
<MemberSection />
<ModelSection />
+ <ProviderSection />
</Show>
<BillingSection />
<MonthlyLimitSection />
diff --git a/packages/console/app/src/routes/workspace/provider-section.module.css b/packages/console/app/src/routes/workspace/provider-section.module.css
new file mode 100644
index 000000000..5f18862f5
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/provider-section.module.css
@@ -0,0 +1,107 @@
+.root {
+ [data-slot="providers-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="providers-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="provider-name"] {
+ color: var(--color-text);
+ font-family: var(--font-mono);
+ font-weight: 500;
+ }
+
+ &[data-slot="provider-status"] {
+ text-align: left;
+ color: var(--color-text);
+ }
+
+ &[data-slot="provider-toggle"] {
+ text-align: left;
+ font-family: var(--font-sans);
+
+ [data-slot="edit-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+
+ [data-slot="input-wrapper"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ input {
+ 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-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ }
+ }
+ }
+ }
+
+ tbody tr {
+ &[data-enabled="false"] {
+ opacity: 0.6;
+ }
+
+ &: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);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/provider-section.tsx b/packages/console/app/src/routes/workspace/provider-section.tsx
new file mode 100644
index 000000000..856b3a6a2
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/provider-section.tsx
@@ -0,0 +1,163 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, For, Show } from "solid-js"
+import { Provider } from "@opencode-ai/console-core/provider.js"
+import { withActor } from "~/context/auth.withActor"
+import { createStore } from "solid-js/store"
+import styles from "./provider-section.module.css"
+
+const PROVIDERS = [
+ { name: "OpenAI", key: "openai", prefix: "sk-" },
+ { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
+] as const
+
+type Provider = (typeof PROVIDERS)[number]
+
+const removeProvider = action(async (form: FormData) => {
+ "use server"
+ const provider = form.get("provider")?.toString()
+ if (!provider) return { error: "Provider is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key })
+}, "provider.remove")
+
+const saveProvider = action(async (form: FormData) => {
+ "use server"
+ const provider = form.get("provider")?.toString()
+ const credentials = form.get("credentials")?.toString()
+ if (!provider) return { error: "Provider is required" }
+ if (!credentials) return { error: "API key is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(
+ await withActor(
+ () =>
+ Provider.create({ provider, credentials })
+ .then(() => ({ error: undefined }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: listProviders.key },
+ )
+}, "provider.save")
+
+const listProviders = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Provider.list(), workspaceID)
+}, "provider.list")
+
+function ProviderRow(props: { provider: Provider }) {
+ const params = useParams()
+ const providers = createAsync(() => listProviders(params.id))
+ const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
+ const removeSubmission = useSubmission(
+ removeProvider,
+ ([fd]) => fd.get("provider")?.toString() === props.provider.key,
+ )
+ const [store, setStore] = createStore({ editing: false })
+
+ let input: HTMLInputElement
+
+ const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key)
+
+ createEffect(() => {
+ if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ while (true) {
+ saveSubmission.clear()
+ if (!saveSubmission.result) break
+ }
+ setStore("editing", true)
+ setTimeout(() => input?.focus(), 0)
+ }
+
+ function hide() {
+ setStore("editing", false)
+ }
+
+ return (
+ <tr data-slot="provider-row" data-enabled={isEnabled()}>
+ <td data-slot="provider-name">{props.provider.name}</td>
+ <td data-slot="provider-status">{isEnabled() ? "Configured" : "Not Configured"}</td>
+ <td data-slot="provider-toggle">
+ <Show
+ when={store.editing}
+ fallback={
+ <Show
+ when={isEnabled()}
+ fallback={
+ <button data-color="ghost" onClick={() => show()}>
+ Configure
+ </button>
+ }
+ >
+ <form action={removeProvider} method="post">
+ <input type="hidden" name="provider" value={props.provider.key} />
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
+ Disable
+ </button>
+ </form>
+ </Show>
+ }
+ >
+ <form action={saveProvider} method="post" data-slot="edit-form">
+ <div data-slot="input-wrapper">
+ <input
+ ref={(r) => (input = r)}
+ name="credentials"
+ type="text"
+ placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`}
+ autocomplete="off"
+ data-form-type="other"
+ data-lpignore="true"
+ />
+ <Show when={saveSubmission.result && saveSubmission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="provider" value={props.provider.key} />
+ <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="ghost" disabled={saveSubmission.pending}>
+ {saveSubmission.pending ? "Saving..." : "Save"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ </td>
+ </tr>
+ )
+}
+
+export function ProviderSection() {
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Bring Your Own Key</h2>
+ <p>Configure your own API keys from AI providers.</p>
+ </div>
+ <div data-slot="providers-table">
+ <table data-slot="providers-table-element">
+ <thead>
+ <tr>
+ <th>Provider</th>
+ <th>Status</th>
+ <th>Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={PROVIDERS}>{(provider) => <ProviderRow provider={provider} />}</For>
+ </tbody>
+ </table>
+ </div>
+ </section>
+ )
+}