|
|
@@ -1,16 +1,4 @@
|
|
|
-import {
|
|
|
- createEffect,
|
|
|
- createMemo,
|
|
|
- createSignal,
|
|
|
- For,
|
|
|
- Match,
|
|
|
- onCleanup,
|
|
|
- onMount,
|
|
|
- ParentProps,
|
|
|
- Show,
|
|
|
- Switch,
|
|
|
- type JSX,
|
|
|
-} from "solid-js"
|
|
|
+import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
|
|
|
import { DateTime } from "luxon"
|
|
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
|
|
import { useLayout, getAvatarColors } from "@/context/layout"
|
|
|
@@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar"
|
|
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
|
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
|
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|
|
import { getFilename } from "@opencode-ai/util/path"
|
|
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
|
-import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
|
|
+import { Session, Project } from "@opencode-ai/sdk/v2/client"
|
|
|
import { usePlatform } from "@/context/platform"
|
|
|
import { createStore, produce } from "solid-js/store"
|
|
|
import {
|
|
|
@@ -40,21 +27,14 @@ import {
|
|
|
useDragDropContext,
|
|
|
} from "@thisbeyond/solid-dnd"
|
|
|
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
|
|
-import { SelectDialog } from "@opencode-ai/ui/select-dialog"
|
|
|
-import { Tag } from "@opencode-ai/ui/tag"
|
|
|
-import { IconName } from "@opencode-ai/ui/icons/provider"
|
|
|
-import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
|
-import { Dialog } from "@opencode-ai/ui/dialog"
|
|
|
-import { iife } from "@opencode-ai/util/iife"
|
|
|
-import { Link } from "@/components/link"
|
|
|
-import { List, ListRef } from "@opencode-ai/ui/list"
|
|
|
-import { TextField } from "@opencode-ai/ui/text-field"
|
|
|
-import { showToast, Toast } from "@opencode-ai/ui/toast"
|
|
|
+import { useProviders } from "@/hooks/use-providers"
|
|
|
+import { Toast } from "@opencode-ai/ui/toast"
|
|
|
import { useGlobalSDK } from "@/context/global-sdk"
|
|
|
-import { Spinner } from "@opencode-ai/ui/spinner"
|
|
|
import { useNotification } from "@/context/notification"
|
|
|
import { Binary } from "@opencode-ai/util/binary"
|
|
|
import { Header } from "@/components/header"
|
|
|
+import { DialogProvider } from "@/components/dialog-provider"
|
|
|
+import { DialogConnect } from "@/components/dialog-connect"
|
|
|
|
|
|
export default function Layout(props: ParentProps) {
|
|
|
const [store, setStore] = createStore({
|
|
|
@@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) {
|
|
|
</div>
|
|
|
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
|
|
|
<Show when={layout.dialog.opened() === "provider"}>
|
|
|
- <SelectDialog
|
|
|
- defaultOpen
|
|
|
- title="Connect provider"
|
|
|
- placeholder="Search providers"
|
|
|
- activeIcon="plus-small"
|
|
|
- key={(x) => x?.id}
|
|
|
- items={providers.all}
|
|
|
- filterKeys={["id", "name"]}
|
|
|
- groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
|
|
|
- sortBy={(a, b) => {
|
|
|
- if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
|
|
- return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
|
|
- return a.name.localeCompare(b.name)
|
|
|
- }}
|
|
|
- sortGroupsBy={(a, b) => {
|
|
|
- if (a.category === "Popular" && b.category !== "Popular") return -1
|
|
|
- if (b.category === "Popular" && a.category !== "Popular") return 1
|
|
|
- return 0
|
|
|
- }}
|
|
|
- onSelect={(x) => {
|
|
|
- if (!x) return
|
|
|
- layout.dialog.connect(x.id)
|
|
|
- }}
|
|
|
- onOpenChange={(open) => {
|
|
|
- if (open) {
|
|
|
- layout.dialog.open("provider")
|
|
|
- } else {
|
|
|
- layout.dialog.close("provider")
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- {(i) => (
|
|
|
- <div class="px-1.25 w-full flex items-center gap-x-4">
|
|
|
- <ProviderIcon
|
|
|
- data-slot="list-item-extra-icon"
|
|
|
- id={i.id as IconName}
|
|
|
- // TODO: clean this up after we update icon in models.dev
|
|
|
- classList={{
|
|
|
- "text-icon-weak-base": true,
|
|
|
- "size-4 mx-0.5": i.id === "opencode",
|
|
|
- "size-5": i.id !== "opencode",
|
|
|
- }}
|
|
|
- />
|
|
|
- <span>{i.name}</span>
|
|
|
- <Show when={i.id === "opencode"}>
|
|
|
- <Tag>Recommended</Tag>
|
|
|
- </Show>
|
|
|
- <Show when={i.id === "anthropic"}>
|
|
|
- <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </SelectDialog>
|
|
|
+ <DialogProvider />
|
|
|
</Show>
|
|
|
<Show when={layout.dialog.opened() === "connect"}>
|
|
|
- {iife(() => {
|
|
|
- const providerID = createMemo(() => layout.connect.provider()!)
|
|
|
- const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
|
|
|
- const methods = createMemo(
|
|
|
- () =>
|
|
|
- globalSync.data.provider_auth[providerID()] ?? [
|
|
|
- {
|
|
|
- type: "api",
|
|
|
- label: "API key",
|
|
|
- },
|
|
|
- ],
|
|
|
- )
|
|
|
- const [store, setStore] = createStore({
|
|
|
- method: undefined as undefined | ProviderAuthMethod,
|
|
|
- authorization: undefined as undefined | ProviderAuthAuthorization,
|
|
|
- state: "pending" as undefined | "pending" | "complete" | "error",
|
|
|
- error: undefined as string | undefined,
|
|
|
- })
|
|
|
-
|
|
|
- const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
|
|
|
-
|
|
|
- async function selectMethod(index: number) {
|
|
|
- const method = methods()[index]
|
|
|
- setStore(
|
|
|
- produce((draft) => {
|
|
|
- draft.method = method
|
|
|
- draft.authorization = undefined
|
|
|
- draft.state = undefined
|
|
|
- draft.error = undefined
|
|
|
- }),
|
|
|
- )
|
|
|
-
|
|
|
- if (method.type === "oauth") {
|
|
|
- setStore("state", "pending")
|
|
|
- const start = Date.now()
|
|
|
- await globalSDK.client.provider.oauth
|
|
|
- .authorize(
|
|
|
- {
|
|
|
- providerID: providerID(),
|
|
|
- method: index,
|
|
|
- },
|
|
|
- { throwOnError: true },
|
|
|
- )
|
|
|
- .then((x) => {
|
|
|
- const elapsed = Date.now() - start
|
|
|
- const delay = 1000 - elapsed
|
|
|
-
|
|
|
- if (delay > 0) {
|
|
|
- setTimeout(() => {
|
|
|
- setStore("state", "complete")
|
|
|
- setStore("authorization", x.data!)
|
|
|
- }, delay)
|
|
|
- return
|
|
|
- }
|
|
|
- setStore("state", "complete")
|
|
|
- setStore("authorization", x.data!)
|
|
|
- })
|
|
|
- .catch((e) => {
|
|
|
- setStore("state", "error")
|
|
|
- setStore("error", String(e))
|
|
|
- })
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- let listRef: ListRef | undefined
|
|
|
- function handleKey(e: KeyboardEvent) {
|
|
|
- if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
|
|
- return
|
|
|
- }
|
|
|
- if (e.key === "Escape") return
|
|
|
- listRef?.onKeyDown(e)
|
|
|
- }
|
|
|
-
|
|
|
- onMount(() => {
|
|
|
- if (methods().length === 1) {
|
|
|
- selectMethod(0)
|
|
|
- }
|
|
|
-
|
|
|
- document.addEventListener("keydown", handleKey)
|
|
|
- onCleanup(() => {
|
|
|
- document.removeEventListener("keydown", handleKey)
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- async function complete() {
|
|
|
- await globalSDK.client.global.dispose()
|
|
|
- setTimeout(() => {
|
|
|
- showToast({
|
|
|
- variant: "success",
|
|
|
- icon: "circle-check",
|
|
|
- title: `${provider().name} connected`,
|
|
|
- description: `${provider().name} models are now available to use.`,
|
|
|
- })
|
|
|
- layout.connect.complete()
|
|
|
- }, 500)
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <Dialog
|
|
|
- modal
|
|
|
- defaultOpen
|
|
|
- onOpenChange={(open) => {
|
|
|
- if (open) {
|
|
|
- layout.dialog.open("connect")
|
|
|
- } else {
|
|
|
- layout.dialog.close("connect")
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- <Dialog.Header class="px-4.5">
|
|
|
- <Dialog.Title class="flex items-center">
|
|
|
- <IconButton
|
|
|
- tabIndex={-1}
|
|
|
- icon="arrow-left"
|
|
|
- variant="ghost"
|
|
|
- onClick={() => {
|
|
|
- if (methods().length === 1) {
|
|
|
- layout.dialog.open("provider")
|
|
|
- return
|
|
|
- }
|
|
|
- if (store.authorization) {
|
|
|
- setStore("authorization", undefined)
|
|
|
- setStore("method", undefined)
|
|
|
- return
|
|
|
- }
|
|
|
- if (store.method) {
|
|
|
- setStore("method", undefined)
|
|
|
- return
|
|
|
- }
|
|
|
- layout.dialog.open("provider")
|
|
|
- }}
|
|
|
- />
|
|
|
- </Dialog.Title>
|
|
|
- <Dialog.CloseButton tabIndex={-1} />
|
|
|
- </Dialog.Header>
|
|
|
- <Dialog.Body>
|
|
|
- <div class="flex flex-col gap-6 px-2.5 pb-3">
|
|
|
- <div class="px-2.5 flex gap-4 items-center">
|
|
|
- <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
|
|
|
- <div class="text-16-medium text-text-strong">
|
|
|
- <Switch>
|
|
|
- <Match
|
|
|
- when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}
|
|
|
- >
|
|
|
- Login with Claude Pro/Max
|
|
|
- </Match>
|
|
|
- <Match when={true}>Connect {provider().name}</Match>
|
|
|
- </Switch>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="px-2.5 pb-10 flex flex-col gap-6">
|
|
|
- <Switch>
|
|
|
- <Match when={store.method === undefined}>
|
|
|
- <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
|
|
|
- <div class="">
|
|
|
- <List
|
|
|
- ref={(ref) => (listRef = ref)}
|
|
|
- items={methods}
|
|
|
- key={(m) => m?.label}
|
|
|
- onSelect={async (method, index) => {
|
|
|
- if (!method) return
|
|
|
- selectMethod(index)
|
|
|
- }}
|
|
|
- >
|
|
|
- {(i) => (
|
|
|
- <div class="w-full flex items-center gap-x-4">
|
|
|
- <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
|
|
- <div
|
|
|
- class="w-2.5 h-0.5 bg-icon-strong-base hidden"
|
|
|
- data-slot="list-item-extra-icon"
|
|
|
- />
|
|
|
- </div>
|
|
|
- <span>{i.label}</span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </List>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={store.state === "pending"}>
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- <div class="flex items-center gap-x-4">
|
|
|
- <Spinner />
|
|
|
- <span>Authorization in progress...</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={store.state === "error"}>
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- <div class="flex items-center gap-x-4">
|
|
|
- <Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
|
|
- <span>Authorization failed: {store.error}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={store.method?.type === "api"}>
|
|
|
- {iife(() => {
|
|
|
- const [formStore, setFormStore] = createStore({
|
|
|
- value: "",
|
|
|
- error: undefined as string | undefined,
|
|
|
- })
|
|
|
-
|
|
|
- async function handleSubmit(e: SubmitEvent) {
|
|
|
- e.preventDefault()
|
|
|
-
|
|
|
- const form = e.currentTarget as HTMLFormElement
|
|
|
- const formData = new FormData(form)
|
|
|
- const apiKey = formData.get("apiKey") as string
|
|
|
-
|
|
|
- if (!apiKey?.trim()) {
|
|
|
- setFormStore("error", "API key is required")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- setFormStore("error", undefined)
|
|
|
- await globalSDK.client.auth.set({
|
|
|
- providerID: providerID(),
|
|
|
- auth: {
|
|
|
- type: "api",
|
|
|
- key: apiKey,
|
|
|
- },
|
|
|
- })
|
|
|
- await complete()
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <div class="flex flex-col gap-6">
|
|
|
- <Switch>
|
|
|
- <Match when={provider().id === "opencode"}>
|
|
|
- <div class="flex flex-col gap-4">
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- OpenCode Zen gives you access to a curated set of reliable optimized models for
|
|
|
- coding agents.
|
|
|
- </div>
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- With a single API key you’ll get access to models such as Claude, GPT, Gemini,
|
|
|
- GLM and more.
|
|
|
- </div>
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- Visit{" "}
|
|
|
- <Link href="https://opencode.ai/zen" tabIndex={-1}>
|
|
|
- opencode.ai/zen
|
|
|
- </Link>{" "}
|
|
|
- to collect your API key.
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={true}>
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- Enter your {provider().name} API key to connect your account and use{" "}
|
|
|
- {provider().name} models in OpenCode.
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
|
- <TextField
|
|
|
- autofocus
|
|
|
- type="text"
|
|
|
- label={`${provider().name} API key`}
|
|
|
- placeholder="API key"
|
|
|
- name="apiKey"
|
|
|
- value={formStore.value}
|
|
|
- onChange={setFormStore.bind(null, "value")}
|
|
|
- validationState={formStore.error ? "invalid" : undefined}
|
|
|
- error={formStore.error}
|
|
|
- />
|
|
|
- <Button class="w-auto" type="submit" size="large" variant="primary">
|
|
|
- Submit
|
|
|
- </Button>
|
|
|
- </form>
|
|
|
- </div>
|
|
|
- )
|
|
|
- })}
|
|
|
- </Match>
|
|
|
- <Match when={store.method?.type === "oauth"}>
|
|
|
- <Switch>
|
|
|
- <Match when={store.authorization?.method === "code"}>
|
|
|
- {iife(() => {
|
|
|
- const [formStore, setFormStore] = createStore({
|
|
|
- value: "",
|
|
|
- error: undefined as string | undefined,
|
|
|
- })
|
|
|
-
|
|
|
- onMount(() => {
|
|
|
- if (store.authorization?.method === "code" && store.authorization?.url) {
|
|
|
- platform.openLink(store.authorization.url)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- async function handleSubmit(e: SubmitEvent) {
|
|
|
- e.preventDefault()
|
|
|
-
|
|
|
- const form = e.currentTarget as HTMLFormElement
|
|
|
- const formData = new FormData(form)
|
|
|
- const code = formData.get("code") as string
|
|
|
-
|
|
|
- if (!code?.trim()) {
|
|
|
- setFormStore("error", "Authorization code is required")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- setFormStore("error", undefined)
|
|
|
- const { error } = await globalSDK.client.provider.oauth.callback({
|
|
|
- providerID: providerID(),
|
|
|
- method: methodIndex(),
|
|
|
- code,
|
|
|
- })
|
|
|
- if (!error) {
|
|
|
- await complete()
|
|
|
- return
|
|
|
- }
|
|
|
- setFormStore("error", "Invalid authorization code")
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <div class="flex flex-col gap-6">
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- Visit <Link href={store.authorization!.url}>this link</Link> to collect your
|
|
|
- authorization code to connect your account and use {provider().name} models in
|
|
|
- OpenCode.
|
|
|
- </div>
|
|
|
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
|
- <TextField
|
|
|
- autofocus
|
|
|
- type="text"
|
|
|
- label={`${store.method?.label} authorization code`}
|
|
|
- placeholder="Authorization code"
|
|
|
- name="code"
|
|
|
- value={formStore.value}
|
|
|
- onChange={setFormStore.bind(null, "value")}
|
|
|
- validationState={formStore.error ? "invalid" : undefined}
|
|
|
- error={formStore.error}
|
|
|
- />
|
|
|
- <Button class="w-auto" type="submit" size="large" variant="primary">
|
|
|
- Submit
|
|
|
- </Button>
|
|
|
- </form>
|
|
|
- </div>
|
|
|
- )
|
|
|
- })}
|
|
|
- </Match>
|
|
|
- <Match when={store.authorization?.method === "auto"}>
|
|
|
- {iife(() => {
|
|
|
- const code = createMemo(() => {
|
|
|
- const instructions = store.authorization?.instructions
|
|
|
- if (instructions?.includes(":")) {
|
|
|
- return instructions?.split(":")[1]?.trim()
|
|
|
- }
|
|
|
- return instructions
|
|
|
- })
|
|
|
-
|
|
|
- onMount(async () => {
|
|
|
- const result = await globalSDK.client.provider.oauth.callback({
|
|
|
- providerID: providerID(),
|
|
|
- method: methodIndex(),
|
|
|
- })
|
|
|
- if (result.error) {
|
|
|
- // TODO: show error
|
|
|
- layout.dialog.close("connect")
|
|
|
- return
|
|
|
- }
|
|
|
- await complete()
|
|
|
- })
|
|
|
-
|
|
|
- return (
|
|
|
- <div class="flex flex-col gap-6">
|
|
|
- <div class="text-14-regular text-text-base">
|
|
|
- Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
|
|
|
- below to connect your account and use {provider().name} models in OpenCode.
|
|
|
- </div>
|
|
|
- <TextField
|
|
|
- label="Confirmation code"
|
|
|
- class="font-mono"
|
|
|
- value={code()}
|
|
|
- readOnly
|
|
|
- copyable
|
|
|
- />
|
|
|
- <div class="text-14-regular text-text-base flex items-center gap-4">
|
|
|
- <Spinner />
|
|
|
- <span>Waiting for authorization...</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
- })}
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Dialog.Body>
|
|
|
- </Dialog>
|
|
|
- )
|
|
|
- })}
|
|
|
+ <DialogConnect />
|
|
|
</Show>
|
|
|
</div>
|
|
|
<Toast.Region />
|