275 lines
8.9 KiB
TypeScript
275 lines
8.9 KiB
TypeScript
import { Popover as Kobalte } from "@kobalte/core/popover"
|
|
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { useLocal } from "@/context/local"
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
import { popularProviders } from "@/hooks/use-providers"
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
import { Tag } from "@opencode-ai/ui/tag"
|
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
import { List } from "@opencode-ai/ui/list"
|
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
|
import { DialogManageModels } from "./dialog-manage-models"
|
|
import { ModelTooltip } from "./model-tooltip"
|
|
import { useLanguage } from "@/context/language"
|
|
|
|
const ModelList: Component<{
|
|
provider?: string
|
|
class?: string
|
|
onSelect: () => void
|
|
action?: JSX.Element
|
|
}> = (props) => {
|
|
const local = useLocal()
|
|
const language = useLanguage()
|
|
|
|
const models = createMemo(() =>
|
|
local.model
|
|
.list()
|
|
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
|
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
|
)
|
|
|
|
return (
|
|
<List
|
|
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
|
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
|
|
emptyMessage={language.t("dialog.model.empty")}
|
|
key={(x) => `${x.provider.id}:${x.id}`}
|
|
items={models}
|
|
current={local.model.current()}
|
|
filterKeys={["provider.name", "name", "id"]}
|
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
|
groupBy={(x) => x.provider.name}
|
|
sortGroupsBy={(a, b) => {
|
|
const aProvider = a.items[0].provider.id
|
|
const bProvider = b.items[0].provider.id
|
|
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
|
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
|
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
|
}}
|
|
itemWrapper={(item, node) => (
|
|
<Tooltip
|
|
class="w-full"
|
|
placement="right-start"
|
|
gutter={12}
|
|
forceMount={false}
|
|
value={
|
|
<ModelTooltip
|
|
model={item}
|
|
latest={item.latest}
|
|
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
|
/>
|
|
}
|
|
>
|
|
{node}
|
|
</Tooltip>
|
|
)}
|
|
onSelect={(x) => {
|
|
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
|
recent: true,
|
|
})
|
|
props.onSelect()
|
|
}}
|
|
>
|
|
{(i) => (
|
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
|
<span class="truncate">{i.name}</span>
|
|
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
|
<Tag>{language.t("model.tag.free")}</Tag>
|
|
</Show>
|
|
<Show when={i.latest}>
|
|
<Tag>{language.t("model.tag.latest")}</Tag>
|
|
</Show>
|
|
</div>
|
|
)}
|
|
</List>
|
|
)
|
|
}
|
|
|
|
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|
provider?: string
|
|
children?: JSX.Element
|
|
triggerAs?: T
|
|
triggerProps?: ComponentProps<T>
|
|
}) {
|
|
const [store, setStore] = createStore<{
|
|
open: boolean
|
|
dismiss: "escape" | "outside" | null
|
|
trigger?: HTMLElement
|
|
content?: HTMLElement
|
|
}>({
|
|
open: false,
|
|
dismiss: null,
|
|
trigger: undefined,
|
|
content: undefined,
|
|
})
|
|
const dialog = useDialog()
|
|
|
|
const handleManage = () => {
|
|
setStore("open", false)
|
|
dialog.show(() => <DialogManageModels />)
|
|
}
|
|
|
|
const handleConnectProvider = () => {
|
|
setStore("open", false)
|
|
dialog.show(() => <DialogSelectProvider />)
|
|
}
|
|
const language = useLanguage()
|
|
|
|
createEffect(() => {
|
|
if (!store.open) return
|
|
|
|
const inside = (node: Node | null | undefined) => {
|
|
if (!node) return false
|
|
const el = store.content
|
|
if (el && el.contains(node)) return true
|
|
const anchor = store.trigger
|
|
if (anchor && anchor.contains(node)) return true
|
|
return false
|
|
}
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return
|
|
setStore("dismiss", "escape")
|
|
setStore("open", false)
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
const target = event.target
|
|
if (!(target instanceof Node)) return
|
|
if (inside(target)) return
|
|
setStore("dismiss", "outside")
|
|
setStore("open", false)
|
|
}
|
|
|
|
const onFocusIn = (event: FocusEvent) => {
|
|
if (!store.content) return
|
|
const target = event.target
|
|
if (!(target instanceof Node)) return
|
|
if (inside(target)) return
|
|
setStore("dismiss", "outside")
|
|
setStore("open", false)
|
|
}
|
|
|
|
window.addEventListener("keydown", onKeyDown, true)
|
|
window.addEventListener("pointerdown", onPointerDown, true)
|
|
window.addEventListener("focusin", onFocusIn, true)
|
|
|
|
onCleanup(() => {
|
|
window.removeEventListener("keydown", onKeyDown, true)
|
|
window.removeEventListener("pointerdown", onPointerDown, true)
|
|
window.removeEventListener("focusin", onFocusIn, true)
|
|
})
|
|
})
|
|
|
|
return (
|
|
<Kobalte
|
|
open={store.open}
|
|
onOpenChange={(next) => {
|
|
if (next) setStore("dismiss", null)
|
|
setStore("open", next)
|
|
}}
|
|
modal={false}
|
|
placement="top-start"
|
|
gutter={8}
|
|
>
|
|
<Kobalte.Trigger
|
|
ref={(el) => setStore("trigger", el)}
|
|
as={props.triggerAs ?? "div"}
|
|
{...(props.triggerProps as any)}
|
|
>
|
|
{props.children}
|
|
</Kobalte.Trigger>
|
|
<Kobalte.Portal>
|
|
<Kobalte.Content
|
|
ref={(el) => setStore("content", el)}
|
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
|
onEscapeKeyDown={(event) => {
|
|
setStore("dismiss", "escape")
|
|
setStore("open", false)
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}}
|
|
onPointerDownOutside={() => {
|
|
setStore("dismiss", "outside")
|
|
setStore("open", false)
|
|
}}
|
|
onFocusOutside={() => {
|
|
setStore("dismiss", "outside")
|
|
setStore("open", false)
|
|
}}
|
|
onCloseAutoFocus={(event) => {
|
|
if (store.dismiss === "outside") event.preventDefault()
|
|
setStore("dismiss", null)
|
|
}}
|
|
>
|
|
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
|
<ModelList
|
|
provider={props.provider}
|
|
onSelect={() => setStore("open", false)}
|
|
class="p-1"
|
|
action={
|
|
<div class="flex items-center gap-1">
|
|
<Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}>
|
|
<IconButton
|
|
icon="plus-small"
|
|
variant="ghost"
|
|
iconSize="normal"
|
|
class="size-6"
|
|
aria-label={language.t("command.provider.connect")}
|
|
onClick={handleConnectProvider}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
|
|
<IconButton
|
|
icon="sliders"
|
|
variant="ghost"
|
|
iconSize="normal"
|
|
class="size-6"
|
|
aria-label={language.t("dialog.model.manage")}
|
|
onClick={handleManage}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
}
|
|
/>
|
|
</Kobalte.Content>
|
|
</Kobalte.Portal>
|
|
</Kobalte>
|
|
)
|
|
}
|
|
|
|
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
|
|
return (
|
|
<Dialog
|
|
title={language.t("dialog.model.select.title")}
|
|
action={
|
|
<Button
|
|
class="h-7 -my-1 text-14-medium"
|
|
icon="plus-small"
|
|
tabIndex={-1}
|
|
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
|
>
|
|
{language.t("command.provider.connect")}
|
|
</Button>
|
|
}
|
|
>
|
|
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
|
<Button
|
|
variant="ghost"
|
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
|
onClick={() => dialog.show(() => <DialogManageModels />)}
|
|
>
|
|
{language.t("dialog.model.manage")}
|
|
</Button>
|
|
</Dialog>
|
|
)
|
|
}
|