From e6c3c862377122be5635c4797c2a7195186ef39c Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Sat, 1 Aug 2026 21:26:35 +0800 Subject: [PATCH 1/5] feat(model-selector): add model selector component --- packages/components/src/index.ts | 5 + .../components/ModelSelectorEffort.vue | 38 ++ .../components/ModelSelectorGroup.vue | 61 ++ .../components/ModelSelectorItem.vue | 81 +++ .../components/ModelSelectorPanel.vue | 159 +++++ .../components/ModelSelectorTrigger.vue | 61 ++ .../composables/useModelSelectorEffort.ts | 68 ++ .../composables/useModelSelectorFilter.ts | 74 ++ .../composables/useModelSelectorFloating.ts | 152 +++++ .../composables/useModelSelectorNavigation.ts | 129 ++++ .../composables/useModelSelectorState.ts | 95 +++ .../components/src/model-selector/index.less | 481 +++++++++++++ .../components/src/model-selector/index.ts | 14 + .../src/model-selector/index.type.ts | 119 ++++ .../components/src/model-selector/index.vue | 634 ++++++++++++++++++ .../src/model-selector/internal.type.ts | 26 + .../model-selector/normalizeModelEfforts.ts | 50 ++ .../model-selector/normalizeModelOptions.ts | 69 ++ .../src/styles/components/index.css | 2 +- .../src/styles/components/model-selector.less | 48 ++ 20 files changed, 2365 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/model-selector/components/ModelSelectorEffort.vue create mode 100644 packages/components/src/model-selector/components/ModelSelectorGroup.vue create mode 100644 packages/components/src/model-selector/components/ModelSelectorItem.vue create mode 100644 packages/components/src/model-selector/components/ModelSelectorPanel.vue create mode 100644 packages/components/src/model-selector/components/ModelSelectorTrigger.vue create mode 100644 packages/components/src/model-selector/composables/useModelSelectorEffort.ts create mode 100644 packages/components/src/model-selector/composables/useModelSelectorFilter.ts create mode 100644 packages/components/src/model-selector/composables/useModelSelectorFloating.ts create mode 100644 packages/components/src/model-selector/composables/useModelSelectorNavigation.ts create mode 100644 packages/components/src/model-selector/composables/useModelSelectorState.ts create mode 100644 packages/components/src/model-selector/index.less create mode 100644 packages/components/src/model-selector/index.ts create mode 100644 packages/components/src/model-selector/index.type.ts create mode 100644 packages/components/src/model-selector/index.vue create mode 100644 packages/components/src/model-selector/internal.type.ts create mode 100644 packages/components/src/model-selector/normalizeModelEfforts.ts create mode 100644 packages/components/src/model-selector/normalizeModelOptions.ts create mode 100644 packages/components/src/styles/components/model-selector.less diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index ff084365d..700820f16 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -13,6 +13,7 @@ import Feedback from './feedback' import History from './history' import IconButton from './icon-button' import { Layout, LayoutAsideToggle, LayoutProxyScrollbar } from './layout' +import ModelSelector from './model-selector' import { Prompt, Prompts } from './prompts' import Sender from './sender' import SenderCompat from './sender-compat' @@ -45,6 +46,7 @@ export * from './feedback/index.type' export * from './history/index.type' export * from './icon-button/index.type' export * from './layout/index.type' +export * from './model-selector/index.type' export * from './prompts/index.type' export * from './sender/index.type' export * from './sender-actions/index.type' @@ -89,6 +91,7 @@ const components = [ Layout, LayoutProxyScrollbar, LayoutAsideToggle, + ModelSelector, Prompt, Prompts, Sender, @@ -149,6 +152,8 @@ export { LayoutProxyScrollbar as TrLayoutProxyScrollbar, LayoutAsideToggle, LayoutAsideToggle as TrLayoutAsideToggle, + ModelSelector, + ModelSelector as TrModelSelector, Prompt, Prompt as TrPrompt, Prompts, diff --git a/packages/components/src/model-selector/components/ModelSelectorEffort.vue b/packages/components/src/model-selector/components/ModelSelectorEffort.vue new file mode 100644 index 000000000..efbb3a472 --- /dev/null +++ b/packages/components/src/model-selector/components/ModelSelectorEffort.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/components/src/model-selector/components/ModelSelectorGroup.vue b/packages/components/src/model-selector/components/ModelSelectorGroup.vue new file mode 100644 index 000000000..494452c11 --- /dev/null +++ b/packages/components/src/model-selector/components/ModelSelectorGroup.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/components/src/model-selector/components/ModelSelectorItem.vue b/packages/components/src/model-selector/components/ModelSelectorItem.vue new file mode 100644 index 000000000..df93785f7 --- /dev/null +++ b/packages/components/src/model-selector/components/ModelSelectorItem.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/components/src/model-selector/components/ModelSelectorPanel.vue b/packages/components/src/model-selector/components/ModelSelectorPanel.vue new file mode 100644 index 000000000..5b0b418a8 --- /dev/null +++ b/packages/components/src/model-selector/components/ModelSelectorPanel.vue @@ -0,0 +1,159 @@ + + + diff --git a/packages/components/src/model-selector/components/ModelSelectorTrigger.vue b/packages/components/src/model-selector/components/ModelSelectorTrigger.vue new file mode 100644 index 000000000..97fc75dcf --- /dev/null +++ b/packages/components/src/model-selector/components/ModelSelectorTrigger.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/components/src/model-selector/composables/useModelSelectorEffort.ts b/packages/components/src/model-selector/composables/useModelSelectorEffort.ts new file mode 100644 index 000000000..7ef12e56e --- /dev/null +++ b/packages/components/src/model-selector/composables/useModelSelectorEffort.ts @@ -0,0 +1,68 @@ +import { computed, shallowRef, watch } from 'vue' +import type { ModelSelectorEffortOption, ModelSelectorEffortValue } from '../index.type' + +interface UseModelSelectorEffortOptions { + value: () => ModelSelectorEffortValue | undefined + defaultValue: ModelSelectorEffortValue + efforts: () => readonly ModelSelectorEffortOption[] + controlled: boolean + onUpdateValue: (value: ModelSelectorEffortValue) => void +} + +export function useModelSelectorEffort(options: UseModelSelectorEffortOptions) { + const isControlled = options.controlled + const internalValue = shallowRef(options.defaultValue) + + if (import.meta.env.DEV) { + watch( + () => options.value() !== undefined, + (controlled) => { + if (controlled !== isControlled) { + console.warn( + '[TrModelSelector] effort cannot switch between controlled and uncontrolled modes during the component lifetime.', + ) + } + }, + { flush: 'sync' }, + ) + } + + const value = computed(() => { + if (!isControlled) { + return internalValue.value + } + + return options.value() ?? null + }) + + const activeOption = computed(() => { + return options.efforts().find((option) => option.value === value.value) ?? null + }) + + function setValue(nextValue: ModelSelectorEffortValue) { + if (Object.is(value.value, nextValue)) { + return false + } + + if (nextValue !== null) { + const nextOption = options.efforts().find((option) => option.value === nextValue) + + if (!nextOption || nextOption.disabled) { + return false + } + } + + if (!isControlled) { + internalValue.value = nextValue + } + + options.onUpdateValue(nextValue) + return true + } + + return { + value, + activeOption, + setValue, + } +} diff --git a/packages/components/src/model-selector/composables/useModelSelectorFilter.ts b/packages/components/src/model-selector/composables/useModelSelectorFilter.ts new file mode 100644 index 000000000..bd2dd0b88 --- /dev/null +++ b/packages/components/src/model-selector/composables/useModelSelectorFilter.ts @@ -0,0 +1,74 @@ +import { computed, shallowRef } from 'vue' +import type { ModelSelectorFilterMethod } from '../index.type' +import type { ModelSelectorOptionGroup, NormalizedModelSelectorOption } from '../internal.type' + +interface UseModelSelectorFilterOptions { + options: () => readonly NormalizedModelSelectorOption[] + searchable: () => boolean + filterMethod: () => ModelSelectorFilterMethod | undefined +} + +export function useModelSelectorFilter(options: UseModelSelectorFilterOptions) { + const query = shallowRef('') + const normalizedQuery = computed(() => query.value.trim()) + + const visibleOptions = computed(() => { + const resolvedOptions = options.options() + const keyword = normalizedQuery.value + + if (!options.searchable() || !keyword) { + return resolvedOptions + } + + const customFilter = options.filterMethod() + + if (customFilter) { + return resolvedOptions.filter((option) => customFilter(keyword, option.raw)) + } + + const normalizedKeyword = keyword.toLocaleLowerCase() + return resolvedOptions.filter((option) => option.searchText.includes(normalizedKeyword)) + }) + + const groups = computed(() => { + const groupMap = new Map() + + visibleOptions.value.forEach((option) => { + const existingGroup = groupMap.get(option.groupKey) + + if (existingGroup) { + existingGroup.items.push(option) + return + } + + groupMap.set(option.groupKey, { + key: option.groupKey, + index: groupMap.size, + group: option.raw.group?.trim() ?? '', + label: option.groupLabel, + items: [option], + }) + }) + + return [...groupMap.values()] + }) + + const isEmpty = computed(() => visibleOptions.value.length === 0) + + function setQuery(value: string) { + query.value = value + } + + function clearQuery() { + query.value = '' + } + + return { + query, + visibleOptions, + groups, + isEmpty, + setQuery, + clearQuery, + } +} diff --git a/packages/components/src/model-selector/composables/useModelSelectorFloating.ts b/packages/components/src/model-selector/composables/useModelSelectorFloating.ts new file mode 100644 index 000000000..91cef066a --- /dev/null +++ b/packages/components/src/model-selector/composables/useModelSelectorFloating.ts @@ -0,0 +1,152 @@ +import { autoUpdate, computePosition, flip, offset, shift, size, type Placement } from '@floating-ui/dom' +import { nextTick, onScopeDispose, shallowRef, watch, type Ref } from 'vue' + +interface UseModelSelectorFloatingOptions { + referenceEl: Ref + floatingEl: Ref + open: () => boolean + placement: () => Placement + offset: () => number + matchTriggerWidth: () => boolean + teleportTarget: () => Node | null + onOutsidePointerDown: (event: PointerEvent) => void +} + +function eventTargetsElement(event: Event, element: HTMLElement) { + const eventPath = event.composedPath?.() ?? [] + + if (eventPath.includes(element)) { + return true + } + + const NodeConstructor = element.ownerDocument.defaultView?.Node + return Boolean(NodeConstructor && event.target instanceof NodeConstructor && element.contains(event.target)) +} + +export function useModelSelectorFloating(options: UseModelSelectorFloatingOptions) { + const isPositioned = shallowRef(false) + + const stopWatching = watch( + () => + [ + options.open(), + options.referenceEl.value, + options.floatingEl.value, + options.placement(), + options.offset(), + options.matchTriggerWidth(), + options.teleportTarget(), + ] as const, + async ( + [open, referenceEl, floatingEl, placement, offsetValue, matchTriggerWidth, _teleportTarget], + _previous, + onCleanup, + ) => { + isPositioned.value = false + + if (!open || !referenceEl || !floatingEl) { + return + } + + const reference = referenceEl + const floating = floatingEl + let active = true + let positionRequest = 0 + const cleanupAutoUpdate: { current?: () => void } = {} + const ownerDocument = reference.ownerDocument + + const handlePointerDown = (event: PointerEvent) => { + if (eventTargetsElement(event, reference) || eventTargetsElement(event, floating)) { + return + } + + options.onOutsidePointerDown(event) + } + + ownerDocument.addEventListener('pointerdown', handlePointerDown, true) + + onCleanup(() => { + active = false + positionRequest += 1 + cleanupAutoUpdate.current?.() + ownerDocument.removeEventListener('pointerdown', handlePointerDown, true) + isPositioned.value = false + }) + + async function updatePosition() { + const request = ++positionRequest + + try { + const result = await computePosition(reference, floating, { + placement, + strategy: 'fixed', + middleware: [ + offset(offsetValue), + flip({ padding: 8 }), + shift({ padding: 8 }), + size({ + padding: 8, + apply({ availableHeight, availableWidth, rects, elements }) { + const matchedWidth = Math.min(rects.reference.width, Math.max(0, availableWidth)) + + elements.floating.style.minWidth = matchTriggerWidth ? `${Math.round(matchedWidth)}px` : '' + elements.floating.style.maxWidth = `${Math.max(0, Math.floor(availableWidth))}px` + elements.floating.style.setProperty( + '--tr-model-selector-available-height', + `${Math.max(0, Math.floor(availableHeight))}px`, + ) + }, + }), + ], + }) + + if ( + !active || + request !== positionRequest || + !options.open() || + options.referenceEl.value !== reference || + options.floatingEl.value !== floating + ) { + return + } + + const devicePixelRatio = ownerDocument.defaultView?.devicePixelRatio || 1 + const x = Math.round(result.x * devicePixelRatio) / devicePixelRatio + const y = Math.round(result.y * devicePixelRatio) / devicePixelRatio + + Object.assign(floating.style, { + left: `${x}px`, + top: `${y}px`, + }) + + isPositioned.value = true + } catch { + if (active && request === positionRequest) { + isPositioned.value = false + } + } + } + + await nextTick() + + if (!active || !options.open()) { + return + } + + cleanupAutoUpdate.current = autoUpdate(reference, floating, () => { + void updatePosition() + }) + + await updatePosition() + }, + { flush: 'post', immediate: true }, + ) + + onScopeDispose(() => { + stopWatching() + }) + + return { + isPositioned, + } +} diff --git a/packages/components/src/model-selector/composables/useModelSelectorNavigation.ts b/packages/components/src/model-selector/composables/useModelSelectorNavigation.ts new file mode 100644 index 000000000..cb76bd0fd --- /dev/null +++ b/packages/components/src/model-selector/composables/useModelSelectorNavigation.ts @@ -0,0 +1,129 @@ +import { computed, nextTick, shallowRef, watch, type Ref } from 'vue' +import type { ModelSelectorValue } from '../index.type' +import type { NormalizedModelSelectorOption } from '../internal.type' + +interface UseModelSelectorNavigationOptions { + enabled: () => boolean + options: () => readonly NormalizedModelSelectorOption[] + selectedValue: () => ModelSelectorValue + containerEl: Ref +} + +export function useModelSelectorNavigation(options: UseModelSelectorNavigationOptions) { + const highlightedKey = shallowRef(null) + + const highlightedOption = computed(() => { + return options.options().find((option) => option.key === highlightedKey.value) ?? null + }) + + function getSelectableOptions() { + return options.options().filter((option) => !option.disabled) + } + + function setHighlightedKey(key: string | null) { + if (key === null) { + highlightedKey.value = null + return + } + + const option = options.options().find((item) => item.key === key) + + if (option && !option.disabled) { + highlightedKey.value = option.key + } + } + + function resetHighlight() { + highlightedKey.value = null + } + + function highlightBoundary(boundary: 'first' | 'last') { + const selectableOptions = getSelectableOptions() + const option = boundary === 'first' ? selectableOptions[0] : selectableOptions[selectableOptions.length - 1] + highlightedKey.value = option?.key ?? null + } + + function highlightSelectedOrBoundary(boundary: 'first' | 'last' = 'first') { + const selectedValue = options.selectedValue() + const selectedOption = options.options().find((option) => option.value === selectedValue && !option.disabled) + + if (selectedOption) { + highlightedKey.value = selectedOption.key + return + } + + highlightBoundary(boundary) + } + + function moveHighlight(step: 1 | -1) { + const selectableOptions = getSelectableOptions() + + if (selectableOptions.length === 0) { + resetHighlight() + return + } + + const currentIndex = selectableOptions.findIndex((option) => option.key === highlightedKey.value) + + if (currentIndex < 0) { + const boundaryIndex = step === 1 ? 0 : selectableOptions.length - 1 + highlightedKey.value = selectableOptions[boundaryIndex]?.key ?? null + return + } + + const nextIndex = (currentIndex + step + selectableOptions.length) % selectableOptions.length + highlightedKey.value = selectableOptions[nextIndex]?.key ?? null + } + + watch( + () => [options.enabled(), options.options(), options.selectedValue()] as const, + ([enabled, visibleOptions, selectedValue], previous) => { + if (!enabled) { + resetHighlight() + return + } + + const previousSelectedValue = previous?.[2] + const selectedOption = visibleOptions.find((option) => option.value === selectedValue && !option.disabled) + + if (selectedValue !== previousSelectedValue && selectedOption) { + highlightedKey.value = selectedOption.key + return + } + + const highlightedOptionStillVisible = highlightedKey.value + ? visibleOptions.some((option) => option.key === highlightedKey.value && !option.disabled) + : false + + if (!highlightedOptionStillVisible) { + highlightSelectedOrBoundary('first') + } + }, + { immediate: true }, + ) + + watch(highlightedKey, async (key) => { + if (!key || !options.enabled()) { + return + } + + await nextTick() + + const container = options.containerEl.value + const optionElement = Array.from( + container?.querySelectorAll('[data-model-selector-option-key]') ?? [], + ).find((element) => element.dataset.modelSelectorOptionKey === key) + + optionElement?.scrollIntoView({ block: 'nearest' }) + }) + + return { + highlightedKey, + highlightedOption, + setHighlightedKey, + resetHighlight, + highlightBoundary, + highlightSelectedOrBoundary, + moveHighlight, + } +} diff --git a/packages/components/src/model-selector/composables/useModelSelectorState.ts b/packages/components/src/model-selector/composables/useModelSelectorState.ts new file mode 100644 index 000000000..49fd4c63c --- /dev/null +++ b/packages/components/src/model-selector/composables/useModelSelectorState.ts @@ -0,0 +1,95 @@ +import { computed, shallowRef, watch } from 'vue' +import type { ModelSelectorValue } from '../index.type' + +interface UseModelSelectorStateOptions { + value: () => ModelSelectorValue | undefined + defaultValue: ModelSelectorValue + valueControlled: boolean + open: () => boolean | undefined + defaultOpen: boolean + openControlled: boolean + onUpdateValue: (value: ModelSelectorValue) => void + onUpdateOpen: (open: boolean) => void +} + +export function useModelSelectorState(options: UseModelSelectorStateOptions) { + const isValueControlled = options.valueControlled + const isOpenControlled = options.openControlled + const internalValue = shallowRef(options.defaultValue) + const internalOpen = shallowRef(options.defaultOpen) + + if (import.meta.env.DEV) { + watch( + () => options.value() !== undefined, + (controlled) => { + if (controlled !== isValueControlled) { + console.warn( + '[TrModelSelector] modelValue cannot switch between controlled and uncontrolled modes during the component lifetime.', + ) + } + }, + { flush: 'sync' }, + ) + + watch( + () => options.open() !== undefined, + (controlled) => { + if (controlled !== isOpenControlled) { + console.warn( + '[TrModelSelector] open cannot switch between controlled and uncontrolled modes during the component lifetime.', + ) + } + }, + { flush: 'sync' }, + ) + } + + const value = computed(() => { + if (!isValueControlled) { + return internalValue.value + } + + return options.value() ?? null + }) + + const open = computed(() => { + if (!isOpenControlled) { + return internalOpen.value + } + + return Boolean(options.open()) + }) + + function setValue(nextValue: ModelSelectorValue) { + if (Object.is(value.value, nextValue)) { + return false + } + + if (!isValueControlled) { + internalValue.value = nextValue + } + + options.onUpdateValue(nextValue) + return true + } + + function setOpen(nextOpen: boolean) { + if (open.value === nextOpen) { + return false + } + + if (!isOpenControlled) { + internalOpen.value = nextOpen + } + + options.onUpdateOpen(nextOpen) + return true + } + + return { + value, + open, + setValue, + setOpen, + } +} diff --git a/packages/components/src/model-selector/index.less b/packages/components/src/model-selector/index.less new file mode 100644 index 000000000..737769a0a --- /dev/null +++ b/packages/components/src/model-selector/index.less @@ -0,0 +1,481 @@ +.tr-model-selector { + position: relative; + display: inline-block; + min-width: 0; + vertical-align: middle; +} + +.tr-model-selector__anchor { + display: inline-flex; + min-width: 0; +} + +.tr-model-selector button.tr-model-selector__trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + min-width: 0; + max-width: 280px; + gap: 8px; + border: 1px solid transparent; + color: var(--tr-model-selector-trigger-text-color); + cursor: pointer; + font: inherit; + line-height: 1; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease; +} + +.tr-model-selector button.tr-model-selector__trigger--outline { + border-color: var(--tr-model-selector-trigger-outline-border); + background: var(--tr-model-selector-trigger-outline-bg); +} + +.tr-model-selector button.tr-model-selector__trigger--ghost { + border-color: var(--tr-model-selector-trigger-ghost-border); + background: var(--tr-model-selector-trigger-ghost-bg); +} + +.tr-model-selector button.tr-model-selector__trigger--muted { + border-color: var(--tr-model-selector-trigger-muted-border); + background: var(--tr-model-selector-trigger-muted-bg); +} + +.tr-model-selector__trigger--small { + min-height: 28px; + padding: 4px 8px; + border-radius: var(--tr-radius-md); + font-size: var(--tr-font-size-xs); +} + +.tr-model-selector__trigger--normal { + min-height: 34px; + padding: 6px 12px; + border-radius: var(--tr-radius-lg); + font-size: 13px; +} + +.tr-model-selector__trigger--large { + min-height: 40px; + padding: 8px 14px; + border-radius: var(--tr-radius-lg); + font-size: var(--tr-font-size-sm); +} + +.tr-model-selector__trigger:hover:not(:disabled), +.tr-model-selector__trigger.is-open:not(:disabled) { + border-color: var(--tr-model-selector-trigger-hover-border); + background: var(--tr-model-selector-trigger-hover-bg); +} + +.tr-model-selector__trigger:focus-visible { + outline: none; + border-color: var(--tr-color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tr-color-primary) 16%, transparent); +} + +.tr-model-selector__trigger:disabled { + color: var(--tr-model-selector-trigger-disabled-color); + cursor: not-allowed; + opacity: 0.72; +} + +.tr-model-selector__trigger-content { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 8px; +} + +.tr-model-selector__trigger-icon, +.tr-model-selector__trigger-chevron, +.tr-model-selector__search-icon, +.tr-model-selector__option-icon, +.tr-model-selector__option-check { + width: 1em; + height: 1em; + flex: 0 0 auto; +} + +.tr-model-selector__trigger-label { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tr-model-selector__trigger-effort { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + color: var(--tr-model-selector-trigger-effort-color); + font-size: 0.92em; + white-space: nowrap; +} + +.tr-model-selector__trigger-effort::before { + margin-right: 8px; + content: '·'; +} + +.tr-model-selector__trigger-chevron { + color: var(--tr-model-selector-trigger-icon-color); + transition: transform 0.18s ease; +} + +.tr-model-selector__trigger.is-open .tr-model-selector__trigger-chevron { + transform: rotate(180deg); +} + +.tr-model-selector__dropdown-wrapper { + position: fixed; + top: 0; + left: 0; + z-index: var(--tr-z-index-dropdown); + visibility: hidden; + pointer-events: none; +} + +.tr-model-selector__dropdown-wrapper.is-positioned { + visibility: visible; + pointer-events: auto; +} + +.tr-model-selector__dropdown-wrapper.is-positioned .tr-model-selector__dropdown-surface { + animation: tr-model-selector-surface-in 0.16s ease both; +} + +.tr-model-selector__panel { + display: flex; + width: min(320px, calc(100vw - 16px)); + min-width: 100%; + max-height: var(--tr-model-selector-available-height, min(480px, calc(100vh - 16px))); + flex-direction: column; + overflow: hidden; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--tr-model-selector-panel-border) 42%, transparent); + border-radius: var(--tr-radius-xl); + background: var(--tr-model-selector-panel-bg); + box-shadow: var(--tr-model-selector-panel-shadow); + color: var(--tr-model-selector-item-color); +} + +.tr-model-selector__panel--small { + width: min(280px, calc(100vw - 16px)); + font-size: var(--tr-font-size-xs); +} + +.tr-model-selector__panel--normal { + font-size: var(--tr-font-size-sm); +} + +.tr-model-selector__panel--large { + width: min(360px, calc(100vw - 16px)); + font-size: var(--tr-font-size-sm); +} + +.tr-model-selector__panel-header, +.tr-model-selector__footer { + flex: 0 0 auto; + padding: 10px 12px; +} + +.tr-model-selector__panel-header { + border-bottom: 1px solid color-mix(in srgb, var(--tr-model-selector-divider-color) 42%, transparent); +} + +.tr-model-selector__footer { + border-top: 1px solid color-mix(in srgb, var(--tr-model-selector-divider-color) 42%, transparent); +} + +.tr-model-selector__effort { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tr-model-selector__effort-label { + flex: 0 0 auto; + color: var(--tr-model-selector-effort-label-color); + font-size: var(--tr-font-size-xs); + line-height: 20px; +} + +.tr-model-selector__effort-options { + display: flex; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +.tr-model-selector__effort-option { + min-width: 34px; + min-height: 26px; + padding: 3px 8px; + border: 1px solid var(--tr-model-selector-effort-option-border); + border-radius: var(--tr-radius-md); + outline: none; + background: var(--tr-model-selector-effort-option-bg); + color: var(--tr-model-selector-effort-option-color); + cursor: pointer; + font: inherit; + font-size: var(--tr-font-size-xs); + line-height: 18px; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; +} + +.tr-model-selector__effort-option:hover:not(:disabled):not(.is-active) { + background: var(--tr-model-selector-effort-option-hover-bg); +} + +.tr-model-selector__effort-option.is-active { + border-color: var(--tr-model-selector-effort-option-active-border); + background: var(--tr-model-selector-effort-option-active-bg); + color: var(--tr-model-selector-effort-option-active-color); +} + +.tr-model-selector__effort-option:focus-visible { + border-color: var(--tr-color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--tr-color-primary) 16%, transparent); +} + +.tr-model-selector__effort-option:disabled { + color: var(--tr-model-selector-effort-option-disabled-color); + cursor: not-allowed; + opacity: 0.72; +} + +.tr-model-selector__search { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--tr-model-selector-divider-color) 42%, transparent); +} + +.tr-model-selector__search-icon { + color: var(--tr-text-tertiary); +} + +.tr-model-selector__search-input { + width: 100%; + min-width: 0; + padding: 0; + border: none; + outline: none; + background: transparent; + color: var(--tr-text-primary); + font: inherit; + line-height: 22px; +} + +.tr-model-selector__search-input::placeholder { + color: var(--tr-text-tertiary); +} + +.tr-model-selector__content { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: 8px; + outline: none; + scrollbar-width: thin; + scrollbar-color: var(--tr-model-selector-scrollbar-color) transparent; +} + +.tr-model-selector__content.is-empty { + flex: 0 0 auto; + height: 0; + padding: 0; + overflow: hidden; +} + +.tr-model-selector__content::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.tr-model-selector__content::-webkit-scrollbar-track { + background: transparent; +} + +.tr-model-selector__content::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: var(--tr-radius-full); + background: var(--tr-model-selector-scrollbar-color); + background-clip: content-box; +} + +.tr-model-selector__group + .tr-model-selector__group { + margin-top: 8px; +} + +.tr-model-selector__group-title { + overflow: hidden; + padding: 4px 8px 6px; + color: var(--tr-model-selector-group-label-color); + font-size: var(--tr-font-size-xs); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tr-model-selector__option { + display: flex; + width: 100%; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 10px; + box-sizing: border-box; + border-radius: 10px; + background: transparent; + color: var(--tr-model-selector-item-color); + cursor: pointer; + text-align: left; + user-select: none; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.tr-model-selector__option + .tr-model-selector__option { + margin-top: 2px; +} + +.tr-model-selector__option:hover:not(.is-disabled), +.tr-model-selector__option.is-highlighted:not(.is-disabled) { + background: var(--tr-model-selector-item-hover-bg); +} + +.tr-model-selector__option.is-selected { + color: var(--tr-model-selector-item-selected-color); + background: color-mix(in srgb, var(--tr-color-primary) 12%, transparent); +} + +.tr-model-selector__option.is-selected.is-highlighted { + background: color-mix(in srgb, var(--tr-color-primary) 18%, transparent); +} + +.tr-model-selector__option.is-disabled { + color: var(--tr-model-selector-item-disabled-color); + cursor: not-allowed; + opacity: 0.72; +} + +.tr-model-selector__option-main { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.tr-model-selector__option-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.tr-model-selector__option-label, +.tr-model-selector__option-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tr-model-selector__option-label { + color: inherit; + line-height: 20px; +} + +.tr-model-selector__option-description { + color: var(--tr-model-selector-item-description-color); + font-size: var(--tr-font-size-xs); + line-height: 18px; +} + +.tr-model-selector__option-check { + color: var(--tr-model-selector-item-selected-color); +} + +.tr-model-selector__empty { + display: flex; + min-height: 160px; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 16px; + box-sizing: border-box; + color: var(--tr-model-selector-empty-color); + text-align: center; +} + +.tr-model-selector__empty-icon { + width: 72px; + max-width: 40%; + height: auto; +} + +@keyframes tr-model-selector-surface-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tr-model-selector__trigger, + .tr-model-selector__trigger-chevron, + .tr-model-selector__option, + .tr-model-selector__effort-option { + transition: none; + } + + .tr-model-selector__dropdown-wrapper.is-positioned .tr-model-selector__dropdown-surface { + animation: none; + } +} + +@media (max-width: 480px) { + .tr-model-selector__trigger { + max-width: min(240px, calc(100vw - 24px)); + } + + .tr-model-selector__panel, + .tr-model-selector__panel--small, + .tr-model-selector__panel--large { + width: calc(100vw - 16px); + } + + .tr-model-selector__effort { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .tr-model-selector__effort-options { + width: 100%; + justify-content: flex-start; + } +} diff --git a/packages/components/src/model-selector/index.ts b/packages/components/src/model-selector/index.ts new file mode 100644 index 000000000..ed6deca5a --- /dev/null +++ b/packages/components/src/model-selector/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue' +import ModelSelector from './index.vue' +import './index.less' + +ModelSelector.name = 'TrModelSelector' + +const install = function (app: App) { + app.component(ModelSelector.name!, ModelSelector) +} + +ModelSelector.install = install + +export * from './index.type' +export default ModelSelector as typeof ModelSelector & { install: typeof install } diff --git a/packages/components/src/model-selector/index.type.ts b/packages/components/src/model-selector/index.type.ts new file mode 100644 index 000000000..12c7715c5 --- /dev/null +++ b/packages/components/src/model-selector/index.type.ts @@ -0,0 +1,119 @@ +import type { Placement } from '@floating-ui/dom' +import type { Component, StyleValue, VNode } from 'vue' + +export type ModelSelectorValue = string | null +export type ModelSelectorEffortValue = string | null +export type ModelSelectorVariant = 'outline' | 'ghost' | 'muted' +export type ModelSelectorSize = 'small' | 'normal' | 'large' +export type ModelSelectorContentClass = string | readonly string[] | Record + +export interface ModelSelectorEffortOption { + readonly value: string + readonly label: string + readonly disabled?: boolean +} + +export type ModelSelectorEfforts = boolean | readonly ModelSelectorEffortOption[] + +export interface ModelSelectorOption { + value: string + label: string + description?: string + icon?: Component + disabled?: boolean + group?: string + groupLabel?: string + keywords?: readonly string[] + efforts?: ModelSelectorEfforts +} + +export type ModelSelectorFilterMethod = (query: string, option: ModelSelectorOption) => boolean + +export interface ModelSelectorProps { + models?: readonly ModelSelectorOption[] + modelValue?: ModelSelectorValue + defaultValue?: ModelSelectorValue + effort?: ModelSelectorEffortValue + defaultEffort?: ModelSelectorEffortValue + open?: boolean + defaultOpen?: boolean + disabled?: boolean + searchable?: boolean + placeholder?: string + searchPlaceholder?: string + emptyText?: string + filterMethod?: ModelSelectorFilterMethod + variant?: ModelSelectorVariant + size?: ModelSelectorSize + placement?: Placement + offset?: number + appendTo?: string | HTMLElement + matchTriggerWidth?: boolean + contentClass?: ModelSelectorContentClass + contentStyle?: StyleValue + ariaLabel?: string + searchAriaLabel?: string + listAriaLabel?: string + effortLabel?: string + effortAriaLabel?: string +} + +export interface ModelSelectorTriggerSlotProps { + value: ModelSelectorValue + option: ModelSelectorOption | null + label: string + open: boolean + disabled: boolean + /** The effort currently supported by the selected model. Sticky unsupported values resolve to null. */ + effort: ModelSelectorEffortValue + effortOption: ModelSelectorEffortOption | null +} + +export interface ModelSelectorItemSlotProps { + option: ModelSelectorOption + selected: boolean + highlighted: boolean + disabled: boolean +} + +export interface ModelSelectorGroupLabelSlotProps { + group: string + label: string + models: readonly ModelSelectorOption[] +} + +export interface ModelSelectorEmptySlotProps { + query: string +} + +export interface ModelSelectorPanelSlotProps { + value: ModelSelectorValue + option: ModelSelectorOption | null + query: string + close: () => void +} + +export interface ModelSelectorFooterSlotProps extends ModelSelectorPanelSlotProps { + efforts: readonly ModelSelectorEffortOption[] + /** The effort currently supported by the selected model. Sticky unsupported values resolve to null. */ + effort: ModelSelectorEffortValue + effortOption: ModelSelectorEffortOption | null + setEffort: (value: ModelSelectorEffortValue) => void +} + +export interface ModelSelectorSlots { + trigger?: (props: ModelSelectorTriggerSlotProps) => VNode | VNode[] + item?: (props: ModelSelectorItemSlotProps) => VNode | VNode[] + 'group-label'?: (props: ModelSelectorGroupLabelSlotProps) => VNode | VNode[] + empty?: (props: ModelSelectorEmptySlotProps) => VNode | VNode[] + 'panel-header'?: (props: ModelSelectorPanelSlotProps) => VNode | VNode[] + footer?: (props: ModelSelectorFooterSlotProps) => VNode | VNode[] +} + +export interface ModelSelectorEmits { + (event: 'update:modelValue', value: ModelSelectorValue): void + (event: 'change', option: ModelSelectorOption): void + (event: 'update:effort', value: ModelSelectorEffortValue): void + (event: 'effort-change', option: ModelSelectorEffortOption | null): void + (event: 'update:open', open: boolean): void +} diff --git a/packages/components/src/model-selector/index.vue b/packages/components/src/model-selector/index.vue new file mode 100644 index 000000000..164a9dd50 --- /dev/null +++ b/packages/components/src/model-selector/index.vue @@ -0,0 +1,634 @@ + + + diff --git a/packages/components/src/model-selector/internal.type.ts b/packages/components/src/model-selector/internal.type.ts new file mode 100644 index 000000000..a9b1e33c0 --- /dev/null +++ b/packages/components/src/model-selector/internal.type.ts @@ -0,0 +1,26 @@ +import type { ModelSelectorOption } from './index.type' + +export interface NormalizedModelSelectorOption { + key: string + index: number + value: string + label: string + description?: string + icon?: ModelSelectorOption['icon'] + disabled: boolean + groupKey: string + groupLabel: string + keywords: readonly string[] + searchText: string + raw: ModelSelectorOption +} + +export interface ModelSelectorOptionGroup { + key: string + index: number + group: string + label: string + items: NormalizedModelSelectorOption[] +} + +export type ModelSelectorInitialHighlight = 'first' | 'last' diff --git a/packages/components/src/model-selector/normalizeModelEfforts.ts b/packages/components/src/model-selector/normalizeModelEfforts.ts new file mode 100644 index 000000000..d002f99d6 --- /dev/null +++ b/packages/components/src/model-selector/normalizeModelEfforts.ts @@ -0,0 +1,50 @@ +import type { ModelSelectorEffortOption, ModelSelectorEfforts } from './index.type' + +const DEFAULT_MODEL_SELECTOR_EFFORTS: readonly ModelSelectorEffortOption[] = Object.freeze([ + Object.freeze({ value: 'low', label: 'Low' }), + Object.freeze({ value: 'medium', label: 'Medium' }), + Object.freeze({ value: 'high', label: 'High' }), +]) + +export function normalizeModelEfforts(efforts: ModelSelectorEfforts | undefined): readonly ModelSelectorEffortOption[] { + if (!efforts) { + return [] + } + + if (efforts === true) { + return DEFAULT_MODEL_SELECTOR_EFFORTS + } + + const values = new Set() + const normalized: ModelSelectorEffortOption[] = [] + + for (const option of efforts) { + if (values.has(option.value)) { + continue + } + + values.add(option.value) + normalized.push(option) + } + + return normalized +} + +export function getDuplicateModelEffortValues(efforts: ModelSelectorEfforts | undefined): string[] { + if (!efforts || efforts === true) { + return [] + } + + const values = new Set() + const duplicates = new Set() + + for (const option of efforts) { + if (values.has(option.value)) { + duplicates.add(option.value) + } else { + values.add(option.value) + } + } + + return Array.from(duplicates) +} diff --git a/packages/components/src/model-selector/normalizeModelOptions.ts b/packages/components/src/model-selector/normalizeModelOptions.ts new file mode 100644 index 000000000..5d204ff21 --- /dev/null +++ b/packages/components/src/model-selector/normalizeModelOptions.ts @@ -0,0 +1,69 @@ +import { toRaw } from 'vue' +import type { ModelSelectorOption } from './index.type' +import type { NormalizedModelSelectorOption } from './internal.type' + +const DEFAULT_GROUP_KEY = '__tr-model-selector-ungrouped__' + +function normalizeText(value?: string) { + return value?.trim() ?? '' +} + +function createSearchText(parts: Array) { + return parts + .filter((part): part is string => Boolean(part && part.trim())) + .join(' ') + .toLocaleLowerCase() +} + +export function normalizeModelOptions(models: readonly ModelSelectorOption[]): NormalizedModelSelectorOption[] { + const seenValues = new Set() + const normalizedOptions: NormalizedModelSelectorOption[] = [] + + models.forEach((model, index) => { + if (seenValues.has(model.value)) { + return + } + + seenValues.add(model.value) + + const label = normalizeText(model.label) || model.value + const description = normalizeText(model.description) || undefined + const group = normalizeText(model.group) + const explicitGroupLabel = normalizeText(model.groupLabel) + const groupKey = group || explicitGroupLabel || DEFAULT_GROUP_KEY + const groupLabel = explicitGroupLabel || group + const keywords = [...(model.keywords ?? [])] + + normalizedOptions.push({ + key: `value:${model.value}`, + index, + value: model.value, + label, + description, + icon: model.icon ? toRaw(model.icon) : undefined, + disabled: Boolean(model.disabled), + groupKey, + groupLabel, + keywords, + searchText: createSearchText([label, model.value, description, group, explicitGroupLabel, ...keywords]), + raw: model, + }) + }) + + return normalizedOptions +} + +export function getDuplicateModelValues(models: readonly ModelSelectorOption[]) { + const seenValues = new Set() + const duplicateValues = new Set() + + models.forEach((model) => { + if (seenValues.has(model.value)) { + duplicateValues.add(model.value) + } else { + seenValues.add(model.value) + } + }) + + return [...duplicateValues] +} diff --git a/packages/components/src/styles/components/index.css b/packages/components/src/styles/components/index.css index a64493ab4..90eb8f541 100644 --- a/packages/components/src/styles/components/index.css +++ b/packages/components/src/styles/components/index.css @@ -8,6 +8,7 @@ @import './prompt.less'; @import './prompts.less'; @import './layout.less'; +@import './model-selector.less'; [data-tr-color-mode='light'] { color-scheme: light; @@ -16,4 +17,3 @@ [data-tr-color-mode='dark'] { color-scheme: dark; } - diff --git a/packages/components/src/styles/components/model-selector.less b/packages/components/src/styles/components/model-selector.less new file mode 100644 index 000000000..c08e5f0ec --- /dev/null +++ b/packages/components/src/styles/components/model-selector.less @@ -0,0 +1,48 @@ +.tr-model-selector-vars() { + @prefix: tr-model-selector; + + @vars: { + trigger-text-color: var(--tr-text-primary); + trigger-icon-color: var(--tr-text-secondary); + trigger-effort-color: var(--tr-text-tertiary); + trigger-outline-bg: var(--tr-container-bg-default); + trigger-outline-border: var(--tr-border-color-default); + trigger-ghost-bg: transparent; + trigger-ghost-border: transparent; + trigger-muted-bg: var(--tr-container-bg-default-2); + trigger-muted-border: transparent; + trigger-hover-bg: var(--tr-container-bg-hover); + trigger-hover-border: var(--tr-border-color-hover); + trigger-disabled-color: var(--tr-text-disabled); + panel-bg: var(--tr-dropdown-menu-bg-color); + panel-border: var(--tr-border-color-default); + panel-shadow: var(--tr-dropdown-menu-box-shadow); + divider-color: var(--tr-border-color-default); + item-color: var(--tr-dropdown-menu-item-color); + item-description-color: var(--tr-text-tertiary); + item-hover-bg: var(--tr-dropdown-menu-item-hover-bg-color); + item-selected-color: var(--tr-color-primary); + item-disabled-color: var(--tr-text-disabled); + group-label-color: var(--tr-text-tertiary); + empty-color: var(--tr-text-secondary); + scrollbar-color: var(--tr-dropdown-menu-scrollbar-thumb-color); + effort-label-color: var(--tr-text-secondary); + effort-option-color: var(--tr-text-secondary); + effort-option-border: var(--tr-border-color-default); + effort-option-bg: transparent; + effort-option-hover-bg: var(--tr-container-bg-hover); + effort-option-active-color: var(--tr-color-primary); + effort-option-active-border: var(--tr-color-primary); + effort-option-active-bg: color-mix(in srgb, var(--tr-color-primary) 12%, transparent); + effort-option-disabled-color: var(--tr-text-disabled); + }; + + :root, + [data-tr-theme] { + each(@vars, { + --@{prefix}-@{key}: @value; + }); + } +} + +.tr-model-selector-vars(); From 6fe50f20dc8984f2b7b0fb21af5b2f774eecef0e Mon Sep 17 00:00:00 2001 From: SonyLeo <746591437@qq.com> Date: Wed, 26 Aug 2026 23:28:47 -0700 Subject: [PATCH 2/5] refactor(model-selector): rename effort types and update related components --- .../components/ModelSelectorEffort.vue | 8 +-- .../components/ModelSelectorGroup.vue | 4 +- .../components/ModelSelectorPanel.vue | 12 ++-- .../composables/useModelSelectorEffort.ts | 16 ++--- .../composables/useModelSelectorFloating.ts | 2 - .../composables/useModelSelectorNavigation.ts | 3 +- .../composables/useModelSelectorState.ts | 13 ++-- .../src/model-selector/index.type.ts | 42 ++++++----- .../components/src/model-selector/index.vue | 72 ++++++++++--------- .../model-selector/normalizeModelEfforts.ts | 12 ++-- 10 files changed, 93 insertions(+), 91 deletions(-) diff --git a/packages/components/src/model-selector/components/ModelSelectorEffort.vue b/packages/components/src/model-selector/components/ModelSelectorEffort.vue index efbb3a472..42958c167 100644 --- a/packages/components/src/model-selector/components/ModelSelectorEffort.vue +++ b/packages/components/src/model-selector/components/ModelSelectorEffort.vue @@ -1,18 +1,18 @@ diff --git a/packages/components/src/model-selector/components/ModelSelectorGroup.vue b/packages/components/src/model-selector/components/ModelSelectorGroup.vue index 494452c11..52714590b 100644 --- a/packages/components/src/model-selector/components/ModelSelectorGroup.vue +++ b/packages/components/src/model-selector/components/ModelSelectorGroup.vue @@ -1,6 +1,6 @@