返回 oh-my-ppt
ModelActionButton.tsx
根目录 / src / renderer / src / components / model / ModelActionButton.tsx
1 import { useEffect, useState, type ReactElement } from 'react'
2 import { Check, ChevronDown, Loader2, type LucideIcon } from 'lucide-react'
3 import { useT } from '@renderer/i18n'
4 import { cn } from '@renderer/lib/utils'
5 import type { ModelActionState } from '@renderer/hooks/useModelAction'
6 import { Button } from '../ui/Button'
7 import { ButtonGroup, ButtonGroupSeparator } from '../ui/ButtonGroup'
8 import {
9 DropdownMenu,
10 DropdownMenuContent,
11 DropdownMenuItem,
12 DropdownMenuTrigger
13 } from '../ui/DropdownMenu'
14
15 type ModelSplitButtonTone = 'primary' | 'subtle'
16 type ModelSplitButtonSize = 'sm' | 'md'
17
18 interface ModelSplitButtonProps {
19 modelAction: ModelActionState
20 label: string
21 loadingLabel?: string
22 loading?: boolean
23 disabled?: boolean
24 icon?: LucideIcon
25 tone?: ModelSplitButtonTone
26 size?: ModelSplitButtonSize
27 ariaLabel?: string
28 dropdownAlign?: 'start' | 'center' | 'end'
29 className?: string
30 mainClassName?: string
31 triggerClassName?: string
32 onRun: (modelConfigId: string) => void | Promise<void>
33 }
34
35 interface ModelSelectButtonProps {
36 modelAction: ModelActionState
37 disabled?: boolean
38 className?: string
39 dropdownAlign?: 'start' | 'center' | 'end'
40 }
41
42 const runModelAction = (
43 modelConfigId: string | undefined,
44 modelAction: ModelActionState,
45 onRun: (modelConfigId: string) => void | Promise<void>
46 ): void => {
47 void modelAction
48 .ensureModelActive(modelConfigId)
49 .then((resolvedModelConfigId) => {
50 if (!resolvedModelConfigId) return undefined
51 return onRun(resolvedModelConfigId)
52 })
53 .catch((error) => {
54 console.error('[ModelActionButton] model action failed', error)
55 })
56 }
57
58 function ModelMenuItems({
59 modelAction,
60 selectedModelConfigId,
61 onSelectModel
62 }: {
63 modelAction: ModelActionState
64 selectedModelConfigId: string
65 onSelectModel: (modelConfigId: string) => void
66 }): ReactElement {
67 return (
68 <>
69 {modelAction.modelConfigs.map((config) => (
70 <DropdownMenuItem
71 key={config.id}
72 className="py-1.5 text-xs"
73 onSelect={() => onSelectModel(config.id)}
74 >
75 <Check
76 className={cn(
77 'h-4 w-4 shrink-0',
78 config.id === selectedModelConfigId ? 'opacity-100' : 'opacity-0'
79 )}
80 />
81 <span className="min-w-0 flex-1">
82 <span className="block truncate text-xs text-[#33402a]">{config.name}</span>
83 <span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
84 {config.provider} · {config.model}
85 </span>
86 </span>
87 </DropdownMenuItem>
88 ))}
89 </>
90 )
91 }
92
93 export function ModelSplitButton({
94 modelAction,
95 label,
96 loadingLabel,
97 loading = false,
98 disabled = false,
99 icon: Icon,
100 tone = 'primary',
101 size = 'sm',
102 ariaLabel,
103 dropdownAlign = 'end',
104 className,
105 mainClassName,
106 triggerClassName,
107 onRun
108 }: ModelSplitButtonProps): ReactElement {
109 const t = useT()
110 const hasMultiple = modelAction.hasMultipleModelConfigs
111 const activating = Boolean(modelAction.activatingModelConfigId)
112 const busy = loading || activating
113 const disabledState = disabled || busy
114 const isPrimary = tone === 'primary'
115 const RunningIcon = busy ? Loader2 : Icon
116 const [pendingModelConfigId, setPendingModelConfigId] = useState<string | null>(null)
117
118 useEffect(() => {
119 setPendingModelConfigId((current) => {
120 if (!current || modelAction.modelConfigs.some((config) => config.id === current)) {
121 return current
122 }
123 return null
124 })
125 }, [modelAction.modelConfigs])
126
127 const effectiveModelConfigId =
128 pendingModelConfigId ||
129 modelAction.selectedModelConfigId ||
130 modelAction.modelConfigs[0]?.id ||
131 ''
132
133 const runSelectedModelAction = (): void => {
134 setPendingModelConfigId(null)
135 runModelAction(effectiveModelConfigId || undefined, modelAction, onRun)
136 }
137
138 return (
139 <ButtonGroup
140 aria-label={ariaLabel || label}
141 aria-disabled={disabledState}
142 className={cn(
143 isPrimary
144 ? hasMultiple
145 ? 'rounded-full border-0 bg-gradient-to-r from-[#6f8159] to-[#4f613f] shadow-[0_10px_22px_rgba(93,107,77,0.24)]'
146 : 'rounded-full border-0 bg-transparent'
147 : 'h-8 rounded-lg border-[#d8ccb5]/80 bg-[#fffdf8]/76 shadow-none',
148 disabledState && 'cursor-not-allowed opacity-50 shadow-none saturate-75',
149 className
150 )}
151 >
152 <Button
153 type="button"
154 variant={isPrimary && !hasMultiple ? 'default' : 'ghost'}
155 size={size}
156 onClick={runSelectedModelAction}
157 disabled={disabledState}
158 className={cn(
159 hasMultiple
160 ? isPrimary
161 ? 'rounded-none bg-transparent px-4 text-white shadow-none hover:bg-white/10 hover:text-white hover:shadow-none'
162 : 'h-full rounded-none border-0 bg-transparent px-2.5 text-xs text-[#405333] shadow-none hover:bg-[#f3f7ed] hover:text-[#2f3b28] hover:shadow-none'
163 : isPrimary
164 ? 'rounded-full'
165 : 'h-full rounded-lg border-0 bg-transparent px-2.5 text-xs text-[#405333] shadow-none hover:bg-[#f3f7ed] hover:text-[#2f3b28] hover:shadow-none',
166 mainClassName
167 )}
168 >
169 {RunningIcon ? (
170 <RunningIcon
171 className={cn(
172 isPrimary ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5',
173 busy ? 'animate-spin' : ''
174 )}
175 />
176 ) : null}
177 {busy && loadingLabel ? loadingLabel : label}
178 </Button>
179 {hasMultiple && (
180 <>
181 <ButtonGroupSeparator
182 className={isPrimary ? 'bg-white/20' : 'my-2 bg-[#d8ccb5]/80'}
183 />
184 <DropdownMenu>
185 <DropdownMenuTrigger asChild>
186 <Button
187 type="button"
188 variant="ghost"
189 size={size}
190 disabled={disabledState}
191 className={cn(
192 isPrimary
193 ? 'shrink-0 rounded-none border-0 bg-transparent px-2.5 text-white shadow-none hover:bg-white/10 hover:text-white hover:shadow-none'
194 : 'h-full w-8 shrink-0 rounded-none border-0 bg-transparent px-0 text-[#405333] shadow-none hover:bg-[#f3f7ed] hover:text-[#2f3b28] hover:shadow-none',
195 triggerClassName
196 )}
197 aria-label={t('settings.generationModel')}
198 >
199 <ChevronDown className={isPrimary ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
200 </Button>
201 </DropdownMenuTrigger>
202 <DropdownMenuContent align={dropdownAlign} className="w-64">
203 <ModelMenuItems
204 modelAction={modelAction}
205 selectedModelConfigId={effectiveModelConfigId}
206 onSelectModel={setPendingModelConfigId}
207 />
208 </DropdownMenuContent>
209 </DropdownMenu>
210 </>
211 )}
212 </ButtonGroup>
213 )
214 }
215
216 export function ModelSelectButton({
217 modelAction,
218 disabled = false,
219 className,
220 dropdownAlign = 'end'
221 }: ModelSelectButtonProps): ReactElement | null {
222 const t = useT()
223 if (!modelAction.hasMultipleModelConfigs) return null
224
225 const disabledState = disabled || Boolean(modelAction.activatingModelConfigId)
226 const label = modelAction.currentModelConfig?.name || t('settings.generationModel')
227
228 return (
229 <DropdownMenu>
230 <DropdownMenuTrigger asChild>
231 <button
232 type="button"
233 disabled={disabledState}
234 className={cn(
235 'flex h-8 max-w-[10rem] items-center gap-1 rounded-full border border-[#d0c8b8] bg-[#ece6d8] px-2 text-[11px] text-[#5d6b4d] transition-colors hover:bg-[#d4e4c1] hover:text-[#3e4a32] disabled:opacity-40',
236 className
237 )}
238 >
239 <span className="min-w-0 truncate">{label}</span>
240 {modelAction.activatingModelConfigId ? (
241 <Loader2 className="h-3 w-3 animate-spin" />
242 ) : (
243 <ChevronDown className="h-3 w-3" />
244 )}
245 </button>
246 </DropdownMenuTrigger>
247 <DropdownMenuContent align={dropdownAlign} className="w-64">
248 <ModelMenuItems
249 modelAction={modelAction}
250 selectedModelConfigId={modelAction.selectedModelConfigId}
251 onSelectModel={(modelConfigId) => {
252 void modelAction.ensureModelActive(modelConfigId).catch((error) => {
253 console.error('[ModelActionButton] model activation failed', error)
254 })
255 }}
256 />
257 </DropdownMenuContent>
258 </DropdownMenu>
259 )
260 }
261
261 lines Plain Text