|
|
@@ -6,9 +6,7 @@ import {
|
|
|
createMemo,
|
|
|
For,
|
|
|
JSX,
|
|
|
- onCleanup,
|
|
|
Show,
|
|
|
- ValidComponent,
|
|
|
} from "solid-js"
|
|
|
import { createStore } from "solid-js/store"
|
|
|
import { useLocal } from "@/context/local"
|
|
|
@@ -29,6 +27,7 @@ import { ModelTooltip } from "./model-tooltip"
|
|
|
import { useLanguage } from "@/context/language"
|
|
|
import { decode64 } from "@/utils/base64"
|
|
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
|
|
+import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
|
|
import { createEventListener } from "@solid-primitives/event-listener"
|
|
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
|
|
|
|
|
@@ -122,14 +121,13 @@ const ModelList: Component<{
|
|
|
}
|
|
|
|
|
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
|
|
+type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
|
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
|
|
|
|
|
export function ModelSelectorPopover(props: {
|
|
|
provider?: string
|
|
|
model?: ModelState
|
|
|
- children?: JSX.Element
|
|
|
- triggerAs?: ValidComponent
|
|
|
- triggerProps?: ModelSelectorTriggerProps
|
|
|
+ trigger: ModelSelectorTrigger
|
|
|
onClose?: (cause: "escape" | "select") => void
|
|
|
}) {
|
|
|
const [store, setStore] = createStore<{
|
|
|
@@ -174,9 +172,7 @@ export function ModelSelectorPopover(props: {
|
|
|
placement="top-start"
|
|
|
gutter={4}
|
|
|
>
|
|
|
- <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
|
|
- {props.children}
|
|
|
- </Kobalte.Trigger>
|
|
|
+ <Kobalte.Trigger as={props.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"
|
|
|
@@ -237,66 +233,101 @@ export function ModelSelectorPopover(props: {
|
|
|
export function ModelSelectorPopoverV2(props: {
|
|
|
provider?: string
|
|
|
model?: ModelState
|
|
|
- children?: JSX.Element
|
|
|
- triggerAs?: ValidComponent
|
|
|
- triggerProps?: ModelSelectorTriggerProps
|
|
|
+ trigger: ModelSelectorTrigger
|
|
|
onClose?: () => void
|
|
|
}) {
|
|
|
- const model = props.model ?? useLocal().model
|
|
|
- const language = useLanguage()
|
|
|
const dialog = useDialog()
|
|
|
- const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
|
|
- let searchRef: HTMLInputElement | undefined
|
|
|
- let contentRef: HTMLDivElement | undefined
|
|
|
- let restoreTrigger = true
|
|
|
+ const controller = createModelSelectorController({
|
|
|
+ model: props.model,
|
|
|
+ provider: () => props.provider,
|
|
|
+ onSelect: () => props.onClose?.(),
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <ModelSelectorPopoverV2View
|
|
|
+ trigger={props.trigger}
|
|
|
+ models={controller.models}
|
|
|
+ groups={controller.groups}
|
|
|
+ current={controller.current}
|
|
|
+ select={controller.select}
|
|
|
+ onManage={() => {
|
|
|
+ void import("./dialog-manage-models").then((module) => {
|
|
|
+ void dialog.show(() => <module.DialogManageModelsV2 />)
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ onClose={() => props.onClose?.()}
|
|
|
+ />
|
|
|
+ )
|
|
|
+}
|
|
|
|
|
|
+function createModelSelectorController(input: {
|
|
|
+ provider: () => string | undefined
|
|
|
+ model?: ModelState
|
|
|
+ onSelect: () => void
|
|
|
+}) {
|
|
|
+ const model = input.model ?? useLocal().model
|
|
|
const allModels = createMemo(() =>
|
|
|
model
|
|
|
.list()
|
|
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
|
|
- .filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
|
|
+ .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
|
|
)
|
|
|
- const models = createMemo(() => {
|
|
|
- const search = store.search.trim()
|
|
|
- const filtered = search
|
|
|
- ? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
|
- : allModels()
|
|
|
|
|
|
- return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
|
|
- })
|
|
|
- const groups = createMemo(() => {
|
|
|
- const byProvider = new Map<string, ModelItem[]>()
|
|
|
- for (const item of models()) {
|
|
|
- byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
|
|
- }
|
|
|
- return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
|
|
- })
|
|
|
- const keys = () => [...models().map(modelKey), manageKey]
|
|
|
- const current = () => {
|
|
|
- const value = model.current()
|
|
|
- return value ? `${value.provider.id}:${value.id}` : undefined
|
|
|
+ return {
|
|
|
+ models: (search: string) => {
|
|
|
+ const query = search.trim()
|
|
|
+ const filtered = query
|
|
|
+ ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
|
|
+ : allModels()
|
|
|
+ return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
|
|
+ },
|
|
|
+ groups: (models: ModelItem[]) => {
|
|
|
+ const byProvider = new Map<string, ModelItem[]>()
|
|
|
+ for (const item of models) {
|
|
|
+ byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
|
|
+ }
|
|
|
+ return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
|
|
+ },
|
|
|
+ current: () => {
|
|
|
+ const value = model.current()
|
|
|
+ return value ? modelKey(value) : undefined
|
|
|
+ },
|
|
|
+ select: (item: ModelItem) => {
|
|
|
+ model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
|
+ input.onSelect()
|
|
|
+ },
|
|
|
}
|
|
|
+}
|
|
|
+
|
|
|
+function ModelSelectorPopoverV2View(props: {
|
|
|
+ trigger: ModelSelectorTrigger
|
|
|
+ models: (search: string) => ModelItem[]
|
|
|
+ groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
|
|
+ current: () => string | undefined
|
|
|
+ select: (item: ModelItem) => void
|
|
|
+ onManage: () => void
|
|
|
+ onClose: () => void
|
|
|
+}) {
|
|
|
+ const language = useLanguage()
|
|
|
+ const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
|
|
+ let searchRef: HTMLInputElement | undefined
|
|
|
+ let contentRef: HTMLDivElement | undefined
|
|
|
+ const dismiss = createMenuDismissController(() => contentRef)
|
|
|
+
|
|
|
+ const models = createMemo(() => props.models(store.search))
|
|
|
+ const groups = createMemo(() => props.groups(models()))
|
|
|
+ const keys = () => [...models().map(modelKey), manageKey]
|
|
|
const initialActive = () => {
|
|
|
- const selected = current()
|
|
|
+ const selected = props.current()
|
|
|
const options = keys()
|
|
|
if (selected && options.includes(selected)) return selected
|
|
|
return options[0] ?? ""
|
|
|
}
|
|
|
const activeItem = () =>
|
|
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
|
|
- const afterClose = (callback: () => void) => {
|
|
|
- const complete = () => {
|
|
|
- if (contentRef?.isConnected) {
|
|
|
- requestAnimationFrame(complete)
|
|
|
- return
|
|
|
- }
|
|
|
- requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
|
- }
|
|
|
- requestAnimationFrame(complete)
|
|
|
- }
|
|
|
const setOpen = (open: boolean) => {
|
|
|
if (open) {
|
|
|
- restoreTrigger = true
|
|
|
+ dismiss.allowTriggerRestore()
|
|
|
setStore({ open: true, active: initialActive() })
|
|
|
setTimeout(() =>
|
|
|
requestAnimationFrame(() => {
|
|
|
@@ -308,23 +339,15 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
}
|
|
|
setStore({ open: false, search: "", active: "" })
|
|
|
}
|
|
|
- const select = (item: ModelItem) => {
|
|
|
- model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
|
- props.onClose?.()
|
|
|
- }
|
|
|
const selectModel = (item: ModelItem) => {
|
|
|
- restoreTrigger = false
|
|
|
+ dismiss.preventTriggerRestore()
|
|
|
setOpen(false)
|
|
|
- afterClose(() => select(item))
|
|
|
+ dismiss.afterClose(() => props.select(item))
|
|
|
}
|
|
|
const manage = () => {
|
|
|
- restoreTrigger = false
|
|
|
+ dismiss.preventTriggerRestore()
|
|
|
setOpen(false)
|
|
|
- afterClose(() => {
|
|
|
- void import("./dialog-manage-models").then((x) => {
|
|
|
- dialog.show(() => <x.DialogManageModelsV2 />)
|
|
|
- })
|
|
|
- })
|
|
|
+ dismiss.afterClose(props.onManage)
|
|
|
}
|
|
|
const selectActive = () => {
|
|
|
const item = models().find((item) => modelKey(item) === store.active)
|
|
|
@@ -343,10 +366,7 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
|
|
}
|
|
|
const setSearch = (value: string) => {
|
|
|
- const search = value.trim()
|
|
|
- const first = [...allModels()]
|
|
|
- .sort((a, b) => a.name.localeCompare(b.name))
|
|
|
- .find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
|
+ const first = props.models(value)[0]
|
|
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
|
|
}
|
|
|
|
|
|
@@ -362,18 +382,14 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
|
|
|
return (
|
|
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
|
|
- <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
|
|
- {props.children}
|
|
|
- </MenuV2.Trigger>
|
|
|
+ <MenuV2.Trigger as={props.trigger} />
|
|
|
<MenuV2.Portal>
|
|
|
<MenuV2.Content
|
|
|
- ref={(el: HTMLDivElement) => (contentRef = el)}
|
|
|
+ ref={(element: HTMLDivElement) => (contentRef = element)}
|
|
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
|
|
- onPointerDownOutside={() => (restoreTrigger = false)}
|
|
|
- onFocusOutside={() => (restoreTrigger = false)}
|
|
|
- onCloseAutoFocus={(event) => {
|
|
|
- if (!restoreTrigger) event.preventDefault()
|
|
|
- }}
|
|
|
+ onPointerDownOutside={dismiss.preventTriggerRestore}
|
|
|
+ onFocusOutside={dismiss.preventTriggerRestore}
|
|
|
+ onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
|
>
|
|
|
<div class="flex flex-col p-0.5">
|
|
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
|
|
@@ -393,9 +409,9 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
event.stopPropagation()
|
|
|
if (event.key === "Escape") {
|
|
|
event.preventDefault()
|
|
|
- restoreTrigger = false
|
|
|
+ dismiss.preventTriggerRestore()
|
|
|
setOpen(false)
|
|
|
- afterClose(() => props.onClose?.())
|
|
|
+ dismiss.afterClose(props.onClose)
|
|
|
return
|
|
|
}
|
|
|
if (event.altKey || event.metaKey) return
|
|
|
@@ -445,7 +461,7 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
|
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
|
|
</MenuV2.GroupLabel>
|
|
|
- <MenuV2.RadioGroup value={current()}>
|
|
|
+ <MenuV2.RadioGroup value={props.current()}>
|
|
|
<For each={group.items}>
|
|
|
{(item) => (
|
|
|
<TooltipV2
|
|
|
@@ -465,7 +481,7 @@ export function ModelSelectorPopoverV2(props: {
|
|
|
<MenuV2.RadioItem
|
|
|
value={modelKey(item)}
|
|
|
data-option-key={modelKey(item)}
|
|
|
- data-selected-model={current() === modelKey(item) ? true : undefined}
|
|
|
+ data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
|
|
class="scroll-my-6 w-full"
|
|
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
|
|
onMouseEnter={() => {
|