mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d9dcf470 |
@@ -1,3 +1,4 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -12,20 +13,34 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
|
||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
|
||||
type StringForm = Extract<IntegrationForm, { type: "string" }>
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
@@ -370,29 +385,120 @@ function ProviderConnection(props: {
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
onCleanup(() => {
|
||||
alive.value = false
|
||||
if (timer.current === undefined) return
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => props.provider,
|
||||
directory,
|
||||
onComplete: () => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: props.provider, directory: directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID: input.provider,
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const loading = createMemo(() => integration.loading)
|
||||
const methods = createMemo<ConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
return values?.length ? values : fallback()
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "form",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.form" }
|
||||
| { type: "auth.answer"; answer: FormAnswer | undefined }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.form") {
|
||||
draft.state = "form"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.answer") {
|
||||
draft.formAnswer = action.answer
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
@@ -414,6 +520,65 @@ function ProviderConnection(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
if (typeof data?.message === "string" && data.message) return data.message
|
||||
}
|
||||
if (value && typeof value === "object" && "error" in value) {
|
||||
const nested = formatError((value as { error?: unknown }).error, "")
|
||||
if (nested) return nested
|
||||
}
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = (value as { message?: unknown }).message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (value instanceof Error && value.message) return value.message
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number, answer?: FormAnswer) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
}
|
||||
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.form?.length && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
if (method.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
return
|
||||
}
|
||||
if (method.type === "oauth") {
|
||||
if (method.form?.some((field) => field.type !== "string")) {
|
||||
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
await serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function AuthFormView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
@@ -421,7 +586,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
const fields = createMemo<StringForm[]>(() => {
|
||||
const value = controller.currentMethod()
|
||||
const value = method()
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
|
||||
})
|
||||
const matches = (field: StringForm, value: Record<string, string>) => {
|
||||
@@ -434,7 +599,7 @@ function ProviderConnection(props: {
|
||||
const current = createMemo(() => {
|
||||
const all = fields()
|
||||
const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
|
||||
if (index === -1) return undefined
|
||||
if (index === -1) return
|
||||
return {
|
||||
index,
|
||||
field: all[index],
|
||||
@@ -448,14 +613,13 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function next(index: number, value: Record<string, string>) {
|
||||
const selected = controller.methodIndex()
|
||||
if (selected === undefined) return
|
||||
if (store.methodIndex === undefined) return
|
||||
const next = fields().findIndex((field, i) => i > index && matches(field, value))
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
await controller.auth.select(selected, value)
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
@@ -469,12 +633,12 @@ function ProviderConnection(props: {
|
||||
const item = () => current()
|
||||
const text = createMemo(() => {
|
||||
const field = item()?.field
|
||||
if (!field || field.options) return undefined
|
||||
if (!field || field.options) return
|
||||
return field
|
||||
})
|
||||
const select = createMemo(() => {
|
||||
const field = item()?.field
|
||||
if (!field?.options) return undefined
|
||||
if (!field?.options) return
|
||||
return field
|
||||
})
|
||||
|
||||
@@ -545,9 +709,32 @@ function ProviderConnection(props: {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
void selectMethod(0)
|
||||
}
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSync()
|
||||
.refreshProviders()
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
props.onBack()
|
||||
@@ -563,14 +750,14 @@ function ProviderConnection(props: {
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<For each={controller.methods()}>
|
||||
<For each={methods()}>
|
||||
{(item, index) => {
|
||||
const details = () => methodDetails(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => void controller.auth.select(index())}
|
||||
onClick={() => void selectMethod(index())}
|
||||
>
|
||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||
@@ -598,11 +785,11 @@ function ProviderConnection(props: {
|
||||
ref={(ref) => {
|
||||
listRef = ref
|
||||
}}
|
||||
items={controller.methods}
|
||||
items={methods}
|
||||
key={(m) => m?.label ?? m?.type}
|
||||
onSelect={async (selected, index) => {
|
||||
if (!selected) return
|
||||
void controller.auth.select(index)
|
||||
void selectMethod(index)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -635,9 +822,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("apiKey")
|
||||
const apiKey = typeof value === "string" ? value : ""
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||
@@ -645,7 +832,13 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await controller.auth.connectKey(apiKey)
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -760,9 +953,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("code")
|
||||
const code = typeof value === "string" ? value : ""
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||
@@ -770,7 +963,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
setFormStore("error", await controller.auth.completeCode(code))
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -778,14 +984,14 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
||||
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
||||
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
<TextInputV2
|
||||
ref={codeInput}
|
||||
class="!w-full"
|
||||
@@ -817,7 +1023,7 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
@@ -827,9 +1033,7 @@ function ProviderConnection(props: {
|
||||
autofocus={!newLayout()}
|
||||
ref={codeInput}
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", {
|
||||
method: controller.currentMethod()?.label ?? "",
|
||||
})}
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
name="code"
|
||||
value={formStore.value}
|
||||
@@ -847,18 +1051,52 @@ function ProviderConnection(props: {
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = controller.authorization()?.instructions
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!alive.value) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(poll, 1_000)
|
||||
}
|
||||
void poll()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
<ExternalLink href={store.authorization!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
@@ -893,9 +1131,7 @@ function ProviderConnection(props: {
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match
|
||||
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
|
||||
>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
@@ -906,10 +1142,10 @@ function ProviderConnection(props: {
|
||||
<div
|
||||
onKeyDown={handleKey}
|
||||
tabIndex={newLayout() ? undefined : 0}
|
||||
autofocus={!newLayout() && controller.methodIndex() === undefined ? true : undefined}
|
||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={controller.loading()}>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
@@ -917,10 +1153,10 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "pending"}>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
@@ -928,26 +1164,26 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<Match when={store.state === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<Match when={method()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "oauth"}>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const queryClient = useQueryClient()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
}
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({ integrationID: input.provider, location: location() })
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
if (values?.length) return [...values]
|
||||
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as number | undefined,
|
||||
authorization: undefined as Authorization | undefined,
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
disposed: false,
|
||||
}
|
||||
const currentMethod = createMemo(() =>
|
||||
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
|
||||
)
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.form" }
|
||||
| { type: "auth.answer"; answer: FormAnswer | undefined }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: Authorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
const dispatch = (action: Action) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.formAnswer = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.form") {
|
||||
draft.state = "form"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.answer") {
|
||||
draft.formAnswer = action.answer
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cancelPolling = () => {
|
||||
polling.generation++
|
||||
if (polling.timer === undefined) return
|
||||
clearTimeout(polling.timer)
|
||||
polling.timer = undefined
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
await queryClient
|
||||
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
|
||||
.catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
const poll = async (authorization: Authorization, generation: number) => {
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID: options.provider(),
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, status: response.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
}
|
||||
const select = async (index: number, answer?: FormAnswer) => {
|
||||
cancelPolling()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.form?.length && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
if (selected.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
return
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
if (selected.form?.some((field) => field.type !== "string")) {
|
||||
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: options.provider(),
|
||||
methodID: selected.id,
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, authorization: response.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: String(result.error) })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: options.provider(),
|
||||
location: location(),
|
||||
key,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
})
|
||||
await finish()
|
||||
}
|
||||
const completeCode = async (code: string) => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: options.provider(),
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!result.ok) {
|
||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||
return message || language.t("provider.connect.oauth.code.invalid")
|
||||
}
|
||||
await finish()
|
||||
return undefined
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || integration.loading || methods().length !== 1) return
|
||||
auto = true
|
||||
void select(0)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
loading: () => integration.loading,
|
||||
methods,
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
select,
|
||||
reset,
|
||||
connectKey,
|
||||
completeCode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
|
||||
@@ -642,6 +642,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
const selection = local.model.selection()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
@@ -649,6 +650,10 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
})
|
||||
if (selection) {
|
||||
local.model.set(selection)
|
||||
local.model.variant.set(selection.variant)
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ export function layer(
|
||||
Layer.orDie,
|
||||
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
|
||||
)
|
||||
return Layer.merge(logs, yield* Otlp.tracingLayer(options, app))
|
||||
return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, app)))
|
||||
}),
|
||||
).pipe(Layer.catchCause(() => local))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { Layer } from "effect"
|
||||
import { OtlpLogger } from "effect/unstable/observability"
|
||||
import { runID } from "./shared.js"
|
||||
|
||||
@@ -71,39 +71,28 @@ export function loggers(options: Options | undefined, app: App) {
|
||||
]
|
||||
}
|
||||
|
||||
export const tracingLayer = Effect.fnUntraced(function* (options: Options | undefined, app: App) {
|
||||
export async function tracingLayer(options: Options | undefined, app: App) {
|
||||
if (!options?.endpoint) return Layer.empty
|
||||
const [{ layer }, { OTLPTraceExporter }, { BatchSpanProcessor }, { AsyncLocalStorageContextManager }, { context }] =
|
||||
yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => import("@effect/opentelemetry/NodeSdk")),
|
||||
Effect.promise(() => import("@opentelemetry/exporter-trace-otlp-http")),
|
||||
Effect.promise(() => import("@opentelemetry/sdk-trace-base")),
|
||||
Effect.promise(() => import("@opentelemetry/context-async-hooks")),
|
||||
Effect.promise(() => import("@opentelemetry/api")),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||
const { context } = await import("@opentelemetry/api")
|
||||
|
||||
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
|
||||
const manager = new AsyncLocalStorageContextManager()
|
||||
manager.enable()
|
||||
context.setGlobalContextManager(manager)
|
||||
|
||||
const tracing = layer(() => ({
|
||||
return NodeSdk.layer(() => ({
|
||||
resource: resource(app),
|
||||
spanProcessor: new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({
|
||||
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||
new OTLP.OTLPTraceExporter({
|
||||
url: `${options.endpoint}/v1/traces`,
|
||||
headers: parseHeaders(options.headers),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
return Layer.effectContext(
|
||||
Effect.acquireRelease(Scope.make(), (scope, exit) => Scope.close(scope, exit).pipe(Effect.ignoreCause)).pipe(
|
||||
Effect.flatMap((scope) => Layer.buildWithScope(tracing, scope)),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export * as Otlp from "./otlp.js"
|
||||
|
||||
Reference in New Issue
Block a user