wip(app): provider settings

This commit is contained in:
adamelmore
2026-01-25 15:59:56 -06:00
committed by Adam
parent a5b72a7d99
commit 03d884797c
7 changed files with 322 additions and 46 deletions

View File

@@ -39,16 +39,30 @@ export const DialogSettings: Component = () => {
"padding-top": "12px",
}}
>
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<div style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px" }}>
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
</div>
</div>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px" }}>
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
<Tabs.Trigger value="providers">
<Icon name="server" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
@@ -56,31 +70,6 @@ export const DialogSettings: Component = () => {
<span class="text-11-regular">v{platform.version}</span>
</div>
</div>
{/* <Tabs.SectionTitle>Server</Tabs.SectionTitle> */}
{/* <Tabs.Trigger value="permissions"> */}
{/* <Icon name="checklist" /> */}
{/* Permissions */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="providers"> */}
{/* <Icon name="server" /> */}
{/* Providers */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="models"> */}
{/* <Icon name="brain" /> */}
{/* Models */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="agents"> */}
{/* <Icon name="task" /> */}
{/* Agents */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="commands"> */}
{/* <Icon name="console" /> */}
{/* Commands */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="mcp"> */}
{/* <Icon name="mcp" /> */}
{/* MCP */}
{/* </Tabs.Trigger> */}
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
@@ -88,12 +77,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
{/* <Tabs.Content value="permissions" class="no-scrollbar"> */}
{/* <SettingsPermissions /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="providers" class="no-scrollbar"> */}
{/* <SettingsProviders /> */}
{/* </Tabs.Content> */}
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
{/* <Tabs.Content value="models" class="no-scrollbar"> */}
{/* <SettingsModels /> */}
{/* </Tabs.Content> */}

View File

@@ -1,14 +1,153 @@
import { Component } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@opencode-ai/ui/toast"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
export const SettingsProviders: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const providers = useProviders()
const connected = createMemo(() => providers.connected())
const popular = createMemo(() => {
const items = providers.popular().slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: unknown) => (item as ProviderMeta).source
const disconnect = async (providerID: string, name: string) => {
await globalSDK.client.auth
.remove({ providerID })
.then(async () => {
await globalSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.providers.description")}</p>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
<div
class="sticky top-0 z-10"
style={{
background:
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
}}
>
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<Show
when={connected().length > 0}
fallback={
<div class="py-4 text-14-regular text-text-weak">
{language.t("settings.providers.connected.empty")}
</div>
}
>
<For each={connected()}>
{(item) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-regular text-text-strong truncate">{item.name}</span>
<Show when={source(item) === "env"}>
<Tag>{language.t("settings.providers.tag.environment")}</Tag>
</Show>
<Show when={source(item) === "api"}>
<Tag>{language.t("provider.connect.method.apiKey")}</Tag>
</Show>
</div>
<Show when={source(item) === "api"}>
<Button size="small" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={popular()}>
{(item) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-x-3 min-w-0">
<ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-regular text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
<Show when={item.id === "openai"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
</Show>
<Show when={item.id.startsWith("github-copilot")}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
</Show>
</div>
<Button
size="small"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogConnectProvider provider={item.id} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
)}
</For>
</div>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
)

View File

@@ -137,6 +137,9 @@ export const dict = {
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
"model.tag.free": "Free",
"model.tag.latest": "Latest",
"model.provider.anthropic": "Anthropic",
@@ -159,6 +162,8 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving...",
@@ -491,6 +496,7 @@ export const dict = {
"sidebar.project.viewAllSessions": "View all sessions",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
@@ -599,6 +605,10 @@ export const dict = {
"settings.providers.title": "Providers",
"settings.providers.description": "Provider settings will be configurable here.",
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.section.popular": "Popular providers",
"settings.providers.tag.environment": "Environment",
"settings.models.title": "Models",
"settings.models.description": "Model settings will be configurable here.",
"settings.agents.title": "Agents",

View File

@@ -441,6 +441,36 @@ export namespace Server {
return c.json(true)
},
)
.delete(
"/auth/:providerID",
describeRoute({
summary: "Remove auth credentials",
description: "Remove authentication credentials",
operationId: "auth.remove",
responses: {
200: {
description: "Successfully removed authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: z.string(),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
await Auth.remove(providerID)
return c.json(true)
},
)
.get(
"/event",
describeRoute({

View File

@@ -9,6 +9,8 @@ import type {
AppLogResponses,
AppSkillsResponses,
Auth as Auth3,
AuthRemoveErrors,
AuthRemoveResponses,
AuthSetErrors,
AuthSetResponses,
CommandListResponses,
@@ -3054,6 +3056,36 @@ export class Formatter extends HeyApiClient {
}
export class Auth2 extends HeyApiClient {
/**
* Remove auth credentials
*
* Remove authentication credentials
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
providerID: string
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "providerID" },
{ in: "query", key: "directory" },
],
},
],
)
return (options?.client ?? this.client).delete<AuthRemoveResponses, AuthRemoveErrors, ThrowOnError>({
url: "/auth/{providerID}",
...options,
...params,
})
}
/**
* Set auth credentials
*

View File

@@ -4867,6 +4867,35 @@ export type FormatterStatusResponses = {
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
export type AuthRemoveData = {
body?: never
path: {
providerID: string
}
query?: {
directory?: string
}
url: "/auth/{providerID}"
}
export type AuthRemoveErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]
export type AuthRemoveResponses = {
/**
* Successfully removed authentication credentials
*/
200: boolean
}
export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]
export type AuthSetData = {
body?: Auth
path: {

View File

@@ -5709,6 +5709,56 @@
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})"
}
]
},
"delete": {
"operationId": "auth.remove",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "providerID",
"schema": {
"type": "string"
},
"required": true
}
],
"summary": "Remove auth credentials",
"description": "Remove authentication credentials",
"responses": {
"200": {
"description": "Successfully removed authentication credentials",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})"
}
]
}
},
"/event": {