diff options
| author | Frank <[email protected]> | 2025-10-08 13:31:12 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-08 13:31:15 -0400 |
| commit | b168bfe40df1ac9c3185766cdcaed688572c1a8a (patch) | |
| tree | ec736534cc5fc3ab8e43207a6ddc6d62b1a7a927 /packages/console/app/src | |
| parent | 1d621260ff83751e70859c3f4c6a834bb481ed81 (diff) | |
| download | opencode-b168bfe40df1ac9c3185766cdcaed688572c1a8a.tar.gz opencode-b168bfe40df1ac9c3185766cdcaed688572c1a8a.zip | |
wip: zen
Diffstat (limited to 'packages/console/app/src')
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> + ) +} |
