Skip to content

Commit b168bfe

Browse files
committed
wip: zen
1 parent 1d62126 commit b168bfe

File tree

9 files changed

+1233
-0
lines changed

9 files changed

+1233
-0
lines changed

packages/console/app/src/routes/workspace/[id].tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { KeySection } from "./key-section"
88
import { MemberSection } from "./member-section"
99
import { SettingsSection } from "./settings-section"
1010
import { ModelSection } from "./model-section"
11+
import { ProviderSection } from "./provider-section"
1112
import { Show } from "solid-js"
1213
import { createAsync, query, useParams } from "@solidjs/router"
1314
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -52,6 +53,7 @@ export default function () {
5253
<SettingsSection />
5354
<MemberSection />
5455
<ModelSection />
56+
<ProviderSection />
5557
</Show>
5658
<BillingSection />
5759
<MonthlyLimitSection />
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
.root {
2+
[data-slot="providers-table"] {
3+
overflow-x: auto;
4+
}
5+
6+
[data-slot="providers-table-element"] {
7+
width: 100%;
8+
border-collapse: collapse;
9+
font-size: var(--font-size-sm);
10+
11+
thead {
12+
border-bottom: 1px solid var(--color-border);
13+
}
14+
15+
th {
16+
padding: var(--space-3) var(--space-4);
17+
text-align: left;
18+
font-weight: normal;
19+
color: var(--color-text-muted);
20+
text-transform: uppercase;
21+
}
22+
23+
td {
24+
padding: var(--space-3) var(--space-4);
25+
border-bottom: 1px solid var(--color-border-muted);
26+
color: var(--color-text-muted);
27+
font-family: var(--font-mono);
28+
29+
&[data-slot="provider-name"] {
30+
color: var(--color-text);
31+
font-family: var(--font-mono);
32+
font-weight: 500;
33+
}
34+
35+
&[data-slot="provider-status"] {
36+
text-align: left;
37+
color: var(--color-text);
38+
}
39+
40+
&[data-slot="provider-toggle"] {
41+
text-align: left;
42+
font-family: var(--font-sans);
43+
44+
[data-slot="edit-form"] {
45+
display: flex;
46+
flex-direction: column;
47+
gap: var(--space-3);
48+
49+
[data-slot="input-wrapper"] {
50+
display: flex;
51+
flex-direction: column;
52+
gap: var(--space-1);
53+
54+
input {
55+
padding: var(--space-2) var(--space-3);
56+
border: 1px solid var(--color-border);
57+
border-radius: var(--border-radius-sm);
58+
background-color: var(--color-bg);
59+
color: var(--color-text);
60+
font-size: var(--font-size-sm);
61+
font-family: var(--font-mono);
62+
63+
&:focus {
64+
outline: none;
65+
border-color: var(--color-accent);
66+
}
67+
68+
&::placeholder {
69+
color: var(--color-text-disabled);
70+
}
71+
}
72+
73+
[data-slot="form-error"] {
74+
color: var(--color-danger);
75+
font-size: var(--font-size-sm);
76+
line-height: 1.4;
77+
}
78+
}
79+
80+
[data-slot="form-actions"] {
81+
display: flex;
82+
gap: var(--space-2);
83+
}
84+
}
85+
}
86+
}
87+
88+
tbody tr {
89+
&[data-enabled="false"] {
90+
opacity: 0.6;
91+
}
92+
93+
&:last-child td {
94+
border-bottom: none;
95+
}
96+
}
97+
98+
@media (max-width: 40rem) {
99+
100+
th,
101+
td {
102+
padding: var(--space-2) var(--space-3);
103+
font-size: var(--font-size-xs);
104+
}
105+
}
106+
}
107+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
2+
import { createEffect, For, Show } from "solid-js"
3+
import { Provider } from "@opencode-ai/console-core/provider.js"
4+
import { withActor } from "~/context/auth.withActor"
5+
import { createStore } from "solid-js/store"
6+
import styles from "./provider-section.module.css"
7+
8+
const PROVIDERS = [
9+
{ name: "OpenAI", key: "openai", prefix: "sk-" },
10+
{ name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
11+
] as const
12+
13+
type Provider = (typeof PROVIDERS)[number]
14+
15+
const removeProvider = action(async (form: FormData) => {
16+
"use server"
17+
const provider = form.get("provider")?.toString()
18+
if (!provider) return { error: "Provider is required" }
19+
const workspaceID = form.get("workspaceID")?.toString()
20+
if (!workspaceID) return { error: "Workspace ID is required" }
21+
return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key })
22+
}, "provider.remove")
23+
24+
const saveProvider = action(async (form: FormData) => {
25+
"use server"
26+
const provider = form.get("provider")?.toString()
27+
const credentials = form.get("credentials")?.toString()
28+
if (!provider) return { error: "Provider is required" }
29+
if (!credentials) return { error: "API key is required" }
30+
const workspaceID = form.get("workspaceID")?.toString()
31+
if (!workspaceID) return { error: "Workspace ID is required" }
32+
return json(
33+
await withActor(
34+
() =>
35+
Provider.create({ provider, credentials })
36+
.then(() => ({ error: undefined }))
37+
.catch((e) => ({ error: e.message as string })),
38+
workspaceID,
39+
),
40+
{ revalidate: listProviders.key },
41+
)
42+
}, "provider.save")
43+
44+
const listProviders = query(async (workspaceID: string) => {
45+
"use server"
46+
return withActor(() => Provider.list(), workspaceID)
47+
}, "provider.list")
48+
49+
function ProviderRow(props: { provider: Provider }) {
50+
const params = useParams()
51+
const providers = createAsync(() => listProviders(params.id))
52+
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
53+
const removeSubmission = useSubmission(
54+
removeProvider,
55+
([fd]) => fd.get("provider")?.toString() === props.provider.key,
56+
)
57+
const [store, setStore] = createStore({ editing: false })
58+
59+
let input: HTMLInputElement
60+
61+
const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key)
62+
63+
createEffect(() => {
64+
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
65+
hide()
66+
}
67+
})
68+
69+
function show() {
70+
while (true) {
71+
saveSubmission.clear()
72+
if (!saveSubmission.result) break
73+
}
74+
setStore("editing", true)
75+
setTimeout(() => input?.focus(), 0)
76+
}
77+
78+
function hide() {
79+
setStore("editing", false)
80+
}
81+
82+
return (
83+
<tr data-slot="provider-row" data-enabled={isEnabled()}>
84+
<td data-slot="provider-name">{props.provider.name}</td>
85+
<td data-slot="provider-status">{isEnabled() ? "Configured" : "Not Configured"}</td>
86+
<td data-slot="provider-toggle">
87+
<Show
88+
when={store.editing}
89+
fallback={
90+
<Show
91+
when={isEnabled()}
92+
fallback={
93+
<button data-color="ghost" onClick={() => show()}>
94+
Configure
95+
</button>
96+
}
97+
>
98+
<form action={removeProvider} method="post">
99+
<input type="hidden" name="provider" value={props.provider.key} />
100+
<input type="hidden" name="workspaceID" value={params.id} />
101+
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
102+
Disable
103+
</button>
104+
</form>
105+
</Show>
106+
}
107+
>
108+
<form action={saveProvider} method="post" data-slot="edit-form">
109+
<div data-slot="input-wrapper">
110+
<input
111+
ref={(r) => (input = r)}
112+
name="credentials"
113+
type="text"
114+
placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`}
115+
autocomplete="off"
116+
data-form-type="other"
117+
data-lpignore="true"
118+
/>
119+
<Show when={saveSubmission.result && saveSubmission.result.error}>
120+
{(err) => <div data-slot="form-error">{err()}</div>}
121+
</Show>
122+
</div>
123+
<input type="hidden" name="provider" value={props.provider.key} />
124+
<input type="hidden" name="workspaceID" value={params.id} />
125+
<div data-slot="form-actions">
126+
<button type="reset" data-color="ghost" onClick={() => hide()}>
127+
Cancel
128+
</button>
129+
<button type="submit" data-color="ghost" disabled={saveSubmission.pending}>
130+
{saveSubmission.pending ? "Saving..." : "Save"}
131+
</button>
132+
</div>
133+
</form>
134+
</Show>
135+
</td>
136+
</tr>
137+
)
138+
}
139+
140+
export function ProviderSection() {
141+
return (
142+
<section class={styles.root}>
143+
<div data-slot="section-title">
144+
<h2>Bring Your Own Key</h2>
145+
<p>Configure your own API keys from AI providers.</p>
146+
</div>
147+
<div data-slot="providers-table">
148+
<table data-slot="providers-table-element">
149+
<thead>
150+
<tr>
151+
<th>Provider</th>
152+
<th>Status</th>
153+
<th>Action</th>
154+
</tr>
155+
</thead>
156+
<tbody>
157+
<For each={PROVIDERS}>{(provider) => <ProviderRow provider={provider} />}</For>
158+
</tbody>
159+
</table>
160+
</div>
161+
</section>
162+
)
163+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE `provider` (
2+
`id` varchar(30) NOT NULL,
3+
`workspace_id` varchar(30) NOT NULL,
4+
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
5+
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
6+
`time_deleted` timestamp(3),
7+
`provider` varchar(64) NOT NULL,
8+
`credentials` text NOT NULL,
9+
CONSTRAINT `provider_workspace_id_id_pk` PRIMARY KEY(`workspace_id`,`id`),
10+
CONSTRAINT `workspace_provider` UNIQUE(`workspace_id`,`provider`)
11+
);

0 commit comments

Comments
 (0)