Files
tf_code/packages/app/src/components/dialog-select-model.tsx

221 lines
7.3 KiB
TypeScript

import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, JSX, 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 isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
action?: JSX.Element
model?: ModelState
}> = (props) => {
const model = props.model ?? useLocal().model
const language = useLanguage()
const models = createMemo(() =>
model
.list()
.filter((m) => 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={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}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
</Tooltip>
)}
onSelect={(x) => {
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={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
<Tag>{language.t("model.tag.latest")}</Tag>
</Show>
</div>
)}
</List>
)
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
}) {
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
}>({
open: false,
dismiss: null,
})
const dialog = useDialog()
const handleManage = () => {
setStore("open", false)
dialog.show(() => <DialogManageModels />)
}
const handleConnectProvider = () => {
setStore("open", false)
dialog.show(() => <DialogSelectProvider />)
}
const language = useLanguage()
return (
<Kobalte
open={store.open}
onOpenChange={(next) => {
if (next) setStore("dismiss", null)
setStore("open", next)
}}
modal={false}
placement="top-start"
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
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}
model={props.model}
onSelect={() => setStore("open", false)}
class="p-1"
action={
<div class="flex items-center gap-1">
<Tooltip placement="top" 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" 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; model?: ModelState }> = (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} model={props.model} 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>
)
}