This commit is contained in:
Frank
2025-10-08 13:31:12 -04:00
parent 1d621260ff
commit b168bfe40d
9 changed files with 1233 additions and 0 deletions

View File

@@ -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 />

View File

@@ -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);
}
}
}
}

View File

@@ -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>
)
}