浏览代码

wip(app): provider settings

adamelmore 3 周之前
父节点
当前提交
03d884797c

+ 27 - 41
packages/app/src/components/dialog-settings.tsx

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

+ 144 - 5
packages/app/src/components/settings-providers.tsx

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

+ 10 - 0
packages/app/src/i18n/en.ts

@@ -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",

+ 30 - 0
packages/opencode/src/server/server.ts

@@ -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({

+ 32 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

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

+ 29 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -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: {

+ 50 - 0
packages/sdk/openapi.json

@@ -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": {