settings-providers.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { Button } from "@opencode-ai/ui/button"
  2. import { useDialog } from "@opencode-ai/ui/context/dialog"
  3. import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
  4. import { Tag } from "@opencode-ai/ui/tag"
  5. import { showToast } from "@opencode-ai/ui/toast"
  6. import { popularProviders, useProviders } from "@/hooks/use-providers"
  7. import { createMemo, type Component, For, Show } from "solid-js"
  8. import { useLanguage } from "@/context/language"
  9. import { useGlobalSDK } from "@/context/global-sdk"
  10. import { useGlobalSync } from "@/context/global-sync"
  11. import { DialogConnectProvider } from "./dialog-connect-provider"
  12. import { DialogSelectProvider } from "./dialog-select-provider"
  13. import { DialogCustomProvider } from "./dialog-custom-provider"
  14. type ProviderSource = "env" | "api" | "config" | "custom"
  15. type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
  16. const PROVIDER_NOTES = [
  17. { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
  18. { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
  19. { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
  20. { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
  21. { match: (id: string) => id === "google", key: "dialog.provider.google.note" },
  22. { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
  23. { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
  24. ] as const
  25. export const SettingsProviders: Component = () => {
  26. const dialog = useDialog()
  27. const language = useLanguage()
  28. const globalSDK = useGlobalSDK()
  29. const globalSync = useGlobalSync()
  30. const providers = useProviders()
  31. const connected = createMemo(() => {
  32. return providers
  33. .connected()
  34. .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
  35. })
  36. const popular = createMemo(() => {
  37. const connectedIDs = new Set(connected().map((p) => p.id))
  38. const items = providers
  39. .popular()
  40. .filter((p) => !connectedIDs.has(p.id))
  41. .slice()
  42. items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
  43. return items
  44. })
  45. const source = (item: ProviderItem): ProviderSource | undefined => {
  46. if (!("source" in item)) return
  47. const value = item.source
  48. if (value === "env" || value === "api" || value === "config" || value === "custom") return value
  49. return
  50. }
  51. const type = (item: ProviderItem) => {
  52. const current = source(item)
  53. if (current === "env") return language.t("settings.providers.tag.environment")
  54. if (current === "api") return language.t("provider.connect.method.apiKey")
  55. if (current === "config") {
  56. if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
  57. return language.t("settings.providers.tag.config")
  58. }
  59. if (current === "custom") return language.t("settings.providers.tag.custom")
  60. return language.t("settings.providers.tag.other")
  61. }
  62. const canDisconnect = (item: ProviderItem) => source(item) !== "env"
  63. const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
  64. const isConfigCustom = (providerID: string) => {
  65. const provider = globalSync.data.config.provider?.[providerID]
  66. if (!provider) return false
  67. if (provider.npm !== "@ai-sdk/openai-compatible") return false
  68. if (!provider.models || Object.keys(provider.models).length === 0) return false
  69. return true
  70. }
  71. const disableProvider = async (providerID: string, name: string) => {
  72. const before = globalSync.data.config.disabled_providers ?? []
  73. const next = before.includes(providerID) ? before : [...before, providerID]
  74. globalSync.set("config", "disabled_providers", next)
  75. await globalSync
  76. .updateConfig({ disabled_providers: next })
  77. .then(() => {
  78. showToast({
  79. variant: "success",
  80. icon: "circle-check",
  81. title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
  82. description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
  83. })
  84. })
  85. .catch((err: unknown) => {
  86. globalSync.set("config", "disabled_providers", before)
  87. const message = err instanceof Error ? err.message : String(err)
  88. showToast({ title: language.t("common.requestFailed"), description: message })
  89. })
  90. }
  91. const disconnect = async (providerID: string, name: string) => {
  92. if (isConfigCustom(providerID)) {
  93. await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
  94. await disableProvider(providerID, name)
  95. return
  96. }
  97. await globalSDK.client.auth
  98. .remove({ providerID })
  99. .then(async () => {
  100. await globalSDK.client.global.dispose()
  101. showToast({
  102. variant: "success",
  103. icon: "circle-check",
  104. title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
  105. description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
  106. })
  107. })
  108. .catch((err: unknown) => {
  109. const message = err instanceof Error ? err.message : String(err)
  110. showToast({ title: language.t("common.requestFailed"), description: message })
  111. })
  112. }
  113. return (
  114. <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
  115. <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
  116. <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
  117. <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
  118. </div>
  119. </div>
  120. <div class="flex flex-col gap-8 max-w-[720px]">
  121. <div class="flex flex-col gap-1" data-component="connected-providers-section">
  122. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
  123. <div class="bg-surface-raised-base px-4 rounded-lg">
  124. <Show
  125. when={connected().length > 0}
  126. fallback={
  127. <div class="py-4 text-14-regular text-text-weak">
  128. {language.t("settings.providers.connected.empty")}
  129. </div>
  130. }
  131. >
  132. <For each={connected()}>
  133. {(item) => (
  134. <div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
  135. <div class="flex items-center gap-3 min-w-0">
  136. <ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
  137. <span class="text-14-medium text-text-strong truncate">{item.name}</span>
  138. <Tag>{type(item)}</Tag>
  139. </div>
  140. <Show
  141. when={canDisconnect(item)}
  142. fallback={
  143. <span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
  144. {language.t("settings.providers.connected.environmentDescription")}
  145. </span>
  146. }
  147. >
  148. <Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
  149. {language.t("common.disconnect")}
  150. </Button>
  151. </Show>
  152. </div>
  153. )}
  154. </For>
  155. </Show>
  156. </div>
  157. </div>
  158. <div class="flex flex-col gap-1">
  159. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
  160. <div class="bg-surface-raised-base px-4 rounded-lg">
  161. <For each={popular()}>
  162. {(item) => (
  163. <div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
  164. <div class="flex flex-col min-w-0">
  165. <div class="flex items-center gap-x-3">
  166. <ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
  167. <span class="text-14-medium text-text-strong">{item.name}</span>
  168. <Show when={item.id === "opencode"}>
  169. <span class="text-14-regular text-text-weak">
  170. {language.t("dialog.provider.opencode.tagline")}
  171. </span>
  172. </Show>
  173. <Show when={item.id === "opencode"}>
  174. <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
  175. </Show>
  176. <Show when={item.id === "opencode-go"}>
  177. <>
  178. <span class="text-14-regular text-text-weak">
  179. {language.t("dialog.provider.opencodeGo.tagline")}
  180. </span>
  181. <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
  182. </>
  183. </Show>
  184. </div>
  185. <Show when={note(item.id)}>
  186. {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
  187. </Show>
  188. </div>
  189. <Button
  190. size="large"
  191. variant="secondary"
  192. icon="plus-small"
  193. onClick={() => {
  194. dialog.show(() => <DialogConnectProvider provider={item.id} />)
  195. }}
  196. >
  197. {language.t("common.connect")}
  198. </Button>
  199. </div>
  200. )}
  201. </For>
  202. <div
  203. class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
  204. data-component="custom-provider-section"
  205. >
  206. <div class="flex flex-col min-w-0">
  207. <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
  208. <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
  209. <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
  210. <Tag>{language.t("settings.providers.tag.custom")}</Tag>
  211. </div>
  212. <span class="text-12-regular text-text-weak pl-8">
  213. {language.t("settings.providers.custom.description")}
  214. </span>
  215. </div>
  216. <Button
  217. size="large"
  218. variant="secondary"
  219. icon="plus-small"
  220. onClick={() => {
  221. dialog.show(() => <DialogCustomProvider back="close" />)
  222. }}
  223. >
  224. {language.t("common.connect")}
  225. </Button>
  226. </div>
  227. </div>
  228. <Button
  229. variant="ghost"
  230. class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
  231. onClick={() => {
  232. dialog.show(() => <DialogSelectProvider />)
  233. }}
  234. >
  235. {language.t("dialog.provider.viewAll")}
  236. </Button>
  237. </div>
  238. </div>
  239. </div>
  240. )
  241. }