@aphrody/koharu-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +37 -2
  2. package/package.json +70 -4
  3. package/src/assets/NotoSansCJK-VF.otf +0 -0
  4. package/src/assets/OFL.txt +92 -0
  5. package/src/components/ClientOnly.tsx +21 -0
  6. package/src/components/ShenronExportPanel.tsx +213 -0
  7. package/src/components/app/AboutDialog.tsx +64 -0
  8. package/src/components/app/KoharuApp.tsx +58 -0
  9. package/src/components/app/StartupView.tsx +67 -0
  10. package/src/components/app/TitleBar.tsx +322 -0
  11. package/src/components/app/Updater.tsx +143 -0
  12. package/src/components/app/WindowChrome.tsx +137 -0
  13. package/src/components/controls/ColorSampling.tsx +70 -0
  14. package/src/components/controls/ColorWell.tsx +139 -0
  15. package/src/components/controls/CommitTextarea.tsx +63 -0
  16. package/src/components/controls/FontPicker.tsx +543 -0
  17. package/src/components/controls/ModelPicker.tsx +178 -0
  18. package/src/components/controls/OutputPicker.tsx +170 -0
  19. package/src/components/editor/ActivityCenter.tsx +234 -0
  20. package/src/components/editor/AgentPanel.tsx +352 -0
  21. package/src/components/editor/CanvasCommandBar.tsx +38 -0
  22. package/src/components/editor/CanvasOverlay.tsx +194 -0
  23. package/src/components/editor/CanvasWorkspace.tsx +947 -0
  24. package/src/components/editor/Editor.tsx +56 -0
  25. package/src/components/editor/InferenceControl.tsx +457 -0
  26. package/src/components/editor/Inspector.tsx +1073 -0
  27. package/src/components/editor/PageRail.tsx +581 -0
  28. package/src/components/editor/ResourceMonitor.tsx +151 -0
  29. package/src/components/editor/RightSidebar.tsx +50 -0
  30. package/src/components/editor/SelectionControls.tsx +184 -0
  31. package/src/components/editor/StatusBar.tsx +41 -0
  32. package/src/components/editor/ToolBar.tsx +182 -0
  33. package/src/components/editor/useCanvas.ts +194 -0
  34. package/src/components/preferences/GenerationPreferences.tsx +118 -0
  35. package/src/components/preferences/KoharuSettings.tsx +229 -0
  36. package/src/components/preferences/PipelinePreferences.tsx +173 -0
  37. package/src/components/preferences/PreferenceFields.tsx +149 -0
  38. package/src/components/preferences/ProviderPreferences.tsx +173 -0
  39. package/src/components/preferences/SettingsPage.tsx +336 -0
  40. package/src/components/preferences/TranslationPreferences.tsx +187 -0
  41. package/src/components/preferences/TypesettingPreferences.tsx +142 -0
  42. package/src/components/preferences/models.ts +103 -0
  43. package/src/components/start/StartView.tsx +277 -0
  44. package/src/index.ts +59 -0
  45. package/src/internal/components/accordion.tsx +72 -0
  46. package/src/internal/components/alert-dialog.tsx +189 -0
  47. package/src/internal/components/alert.tsx +76 -0
  48. package/src/internal/components/aspect-ratio.tsx +22 -0
  49. package/src/internal/components/attachment.tsx +207 -0
  50. package/src/internal/components/avatar.tsx +109 -0
  51. package/src/internal/components/badge.tsx +52 -0
  52. package/src/internal/components/breadcrumb.tsx +125 -0
  53. package/src/internal/components/bubble.tsx +128 -0
  54. package/src/internal/components/button-group.tsx +87 -0
  55. package/src/internal/components/button.tsx +58 -0
  56. package/src/internal/components/card.tsx +103 -0
  57. package/src/internal/components/checkbox.tsx +29 -0
  58. package/src/internal/components/collapsible.tsx +21 -0
  59. package/src/internal/components/combobox.tsx +298 -0
  60. package/src/internal/components/context-menu.tsx +272 -0
  61. package/src/internal/components/dialog.tsx +162 -0
  62. package/src/internal/components/direction.tsx +6 -0
  63. package/src/internal/components/drawer.tsx +230 -0
  64. package/src/internal/components/dropdown-menu.tsx +278 -0
  65. package/src/internal/components/empty.tsx +104 -0
  66. package/src/internal/components/field.tsx +238 -0
  67. package/src/internal/components/hover-card.tsx +52 -0
  68. package/src/internal/components/input-group.tsx +158 -0
  69. package/src/internal/components/input.tsx +20 -0
  70. package/src/internal/components/item.tsx +201 -0
  71. package/src/internal/components/kbd.tsx +26 -0
  72. package/src/internal/components/label.tsx +20 -0
  73. package/src/internal/components/marker.tsx +71 -0
  74. package/src/internal/components/menubar.tsx +280 -0
  75. package/src/internal/components/message.tsx +92 -0
  76. package/src/internal/components/native-select.tsx +61 -0
  77. package/src/internal/components/navigation-menu.tsx +168 -0
  78. package/src/internal/components/number-field.tsx +68 -0
  79. package/src/internal/components/pagination.tsx +130 -0
  80. package/src/internal/components/popover.tsx +91 -0
  81. package/src/internal/components/progress.tsx +83 -0
  82. package/src/internal/components/radio-group.tsx +38 -0
  83. package/src/internal/components/resizable.tsx +50 -0
  84. package/src/internal/components/scroll-area.tsx +88 -0
  85. package/src/internal/components/select.tsx +191 -0
  86. package/src/internal/components/separator.tsx +25 -0
  87. package/src/internal/components/sheet.tsx +140 -0
  88. package/src/internal/components/sidebar.tsx +723 -0
  89. package/src/internal/components/skeleton.tsx +13 -0
  90. package/src/internal/components/slider.tsx +56 -0
  91. package/src/internal/components/spinner.tsx +10 -0
  92. package/src/internal/components/switch.tsx +32 -0
  93. package/src/internal/components/table.tsx +116 -0
  94. package/src/internal/components/tabs.tsx +82 -0
  95. package/src/internal/components/textarea.tsx +18 -0
  96. package/src/internal/components/toast.tsx +235 -0
  97. package/src/internal/components/toggle-group.tsx +89 -0
  98. package/src/internal/components/toggle.tsx +45 -0
  99. package/src/internal/components/tooltip.tsx +67 -0
  100. package/src/internal/hooks/use-mobile.ts +19 -0
  101. package/src/internal/lib/utils.ts +6 -0
  102. package/src/internal/styles/globals.css +73 -0
  103. package/src/lib/backend.ts +85 -0
  104. package/src/lib/document.ts +46 -0
  105. package/src/lib/geometry.ts +272 -0
  106. package/src/lib/i18n.ts +54 -0
  107. package/src/lib/queries.ts +110 -0
  108. package/src/lib/store.ts +191 -0
  109. package/src/lib/translation.ts +35 -0
  110. package/src/locales/en-US/translation.json +536 -0
  111. package/src/locales/es-ES/translation.json +536 -0
  112. package/src/locales/ja-JP/translation.json +536 -0
  113. package/src/locales/ko-KR/translation.json +536 -0
  114. package/src/locales/pt-BR/translation.json +536 -0
  115. package/src/locales/ru-RU/translation.json +536 -0
  116. package/src/locales/tr-TR/translation.json +536 -0
  117. package/src/locales/zh-CN/translation.json +536 -0
  118. package/src/locales/zh-TW/translation.json +536 -0
  119. package/src/navigation.ts +28 -0
  120. package/src/providers.tsx +132 -0
  121. package/src/shenron-pipeline-env.d.ts +14 -0
  122. package/src/styles.css +246 -0
  123. package/tsconfig.json +21 -0
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { ColorSamplingProvider } from '@aphrody/koharu-ui/components/controls/ColorSampling'
4
+ import { ActivityCenter } from '@aphrody/koharu-ui/components/editor/ActivityCenter'
5
+ import { CanvasWorkspace } from '@aphrody/koharu-ui/components/editor/CanvasWorkspace'
6
+ import { PageRail } from '@aphrody/koharu-ui/components/editor/PageRail'
7
+ import { RightSidebar } from '@aphrody/koharu-ui/components/editor/RightSidebar'
8
+ import {
9
+ ResizableHandle,
10
+ ResizablePanel,
11
+ ResizablePanelGroup,
12
+ } from '../../internal/components/resizable'
13
+
14
+ export function Editor() {
15
+ return (
16
+ <ColorSamplingProvider>
17
+ <div data-koharu-ui className='relative min-h-0 flex-1 bg-transparent'>
18
+ <ActivityCenter />
19
+ <ResizablePanelGroup id='editor' orientation='horizontal' className='h-full min-h-0'>
20
+ <ResizablePanel
21
+ id='pages'
22
+ defaultSize='18%'
23
+ minSize='14%'
24
+ maxSize='22%'
25
+ collapsible
26
+ collapsedSize='4%'
27
+ className='min-h-0 overflow-hidden'
28
+ >
29
+ <PageRail />
30
+ </ResizablePanel>
31
+ <ResizableHandle className='w-0 bg-transparent' />
32
+ <ResizablePanel
33
+ id='canvas'
34
+ defaultSize='58%'
35
+ minSize='50%'
36
+ className='workspace-corner-mask relative z-10 min-h-0 rounded-tl-2xl bg-transparent shadow-[var(--shadow-content)]'
37
+ >
38
+ <CanvasWorkspace />
39
+ </ResizablePanel>
40
+ <ResizableHandle className='w-0 bg-transparent' />
41
+ <ResizablePanel
42
+ id='inspector'
43
+ defaultSize='24%'
44
+ minSize='20%'
45
+ maxSize='27%'
46
+ collapsible
47
+ collapsedSize='4%'
48
+ className='min-h-0 overflow-hidden border-l border-border/40 bg-[var(--surface-panel)]'
49
+ >
50
+ <RightSidebar />
51
+ </ResizablePanel>
52
+ </ResizablePanelGroup>
53
+ </div>
54
+ </ColorSamplingProvider>
55
+ )
56
+ }
@@ -0,0 +1,457 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Bot,
5
+ Check,
6
+ ChevronDown,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Play,
10
+ Settings,
11
+ Sparkles,
12
+ Square,
13
+ } from 'lucide-react'
14
+ import { useState, type ReactNode } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+
17
+ import { ModelPicker } from '@aphrody/koharu-ui/components/controls/ModelPicker'
18
+ import { OutputPicker, type OutputDraft } from '@aphrody/koharu-ui/components/controls/OutputPicker'
19
+ import { call, refreshTranslationModels, savePreferences } from '@aphrody/koharu-ui/lib/backend'
20
+ import { pipelineStages, receivePreferences, useKoharuStore, type PipelineScope } from '@aphrody/koharu-ui/lib/store'
21
+ import { modelKey, providerName } from '@aphrody/koharu-ui/lib/translation'
22
+ import {
23
+ commands,
24
+ type Model,
25
+ type ModelSelection,
26
+ type ProviderPreference,
27
+ type Stage,
28
+ } from '@aphrody/koharu-bridge/protocol'
29
+ import { Button } from '../../internal/components/button'
30
+ import { Popover, PopoverContent, PopoverTrigger } from '../../internal/components/popover'
31
+ import { cn } from '../../internal/lib/utils'
32
+
33
+ type SelectorView = 'root' | 'model' | 'scope' | 'stages' | 'output'
34
+
35
+ export function InferenceControl({
36
+ onRun,
37
+ disabled,
38
+ }: {
39
+ onRun: (scope: PipelineScope, stages: Stage[]) => void
40
+ disabled: boolean
41
+ }) {
42
+ const { t } = useTranslation()
43
+ const scope = useKoharuStore((state) => state.processingScope)
44
+ const stages = useKoharuStore((state) => state.processingStages)
45
+ const setScope = useKoharuStore((state) => state.setProcessingScope)
46
+ const setStages = useKoharuStore((state) => state.setProcessingStages)
47
+ const jobs = useKoharuStore((state) => state.jobs)
48
+ const selectedPages = useKoharuStore((state) => state.selectedPages)
49
+ const running = Object.values(jobs).find((job) => job.state === 'running') ?? null
50
+ const unavailable = scope === 'selected-pages' && selectedPages.length === 0
51
+
52
+ const stop = () => {
53
+ if (!running) return
54
+ void call(commands.stopJob, running.id).catch(() => undefined)
55
+ }
56
+
57
+ return (
58
+ <div className='flex items-center gap-1'>
59
+ <RuntimeSelector
60
+ scope={scope}
61
+ stages={stages}
62
+ selectionCount={selectedPages.length}
63
+ running={Boolean(running)}
64
+ onScopeChange={setScope}
65
+ onStagesChange={setStages}
66
+ />
67
+
68
+ <Button
69
+ type='button'
70
+ size='sm'
71
+ className={cn(
72
+ 'rounded-lg px-2.5 text-[11px]',
73
+ !running && 'bg-primary/80 hover:bg-primary/90',
74
+ )}
75
+ disabled={(disabled || unavailable) && !running}
76
+ aria-label={running ? t('inference.stopProcessing') : t('inference.runProcessing')}
77
+ onClick={running ? stop : () => onRun(scope, stages)}
78
+ >
79
+ {running ? <Square className='size-3 fill-current' /> : <Play className='size-3' />}
80
+ <span>{running ? t('inference.stop') : t('inference.run')}</span>
81
+ </Button>
82
+ </div>
83
+ )
84
+ }
85
+
86
+ function RuntimeSelector({
87
+ scope,
88
+ stages,
89
+ selectionCount,
90
+ running,
91
+ onScopeChange,
92
+ onStagesChange,
93
+ }: {
94
+ scope: PipelineScope
95
+ stages: Stage[]
96
+ selectionCount: number
97
+ running: boolean
98
+ onScopeChange: (scope: PipelineScope) => void
99
+ onStagesChange: (stages: Stage[]) => void
100
+ }) {
101
+ const { t } = useTranslation()
102
+ const [open, setOpen] = useState(false)
103
+ const [view, setView] = useState<SelectorView>('root')
104
+ const [loadingModels, setLoadingModels] = useState(false)
105
+ const [savingModel, setSavingModel] = useState<string | null>(null)
106
+ const [savingOutput, setSavingOutput] = useState(false)
107
+ const preferences = useKoharuStore((state) => state.preferences)
108
+ const translationModels = useKoharuStore((state) => state.translationModels)
109
+ const setSettingsOpen = useKoharuStore((state) => state.setSettingsOpen)
110
+ const model = preferences?.pipeline.translation.model ?? null
111
+ const translation = preferences?.pipeline.translation ?? null
112
+ const providers = preferences?.providers.entries ?? []
113
+ const languages = preferences?.languages ?? []
114
+ const choices = availableModels(model, translationModels, providers)
115
+ const modelLabel =
116
+ choices.find((choice) => model && modelKey(choice) === modelKey(model))?.name ??
117
+ (model ? (model.model ?? t('inference.providerDefault')) : t('inference.noModel'))
118
+ const outputLabel =
119
+ languages.find((language) => language.tag === translation?.target_language)?.name ??
120
+ translation?.target_language ??
121
+ t('inference.notSet')
122
+
123
+ const handleOpenChange = (next: boolean) => {
124
+ setOpen(next)
125
+ setView('root')
126
+ if (!next) return
127
+ setLoadingModels(true)
128
+ void refreshTranslationModels(true)
129
+ .catch(() => undefined)
130
+ .finally(() => setLoadingModels(false))
131
+ }
132
+
133
+ const chooseModel = (next: ModelSelection) => {
134
+ if (!preferences || savingModel) return
135
+ if (model && modelKey(model) === modelKey(next) && model.quantization === next.quantization) {
136
+ setView('root')
137
+ return
138
+ }
139
+
140
+ const key = modelKey(next)
141
+ setSavingModel(key)
142
+ const pipeline = {
143
+ ...preferences.pipeline,
144
+ translation: {
145
+ ...preferences.pipeline.translation,
146
+ model: next,
147
+ },
148
+ }
149
+ void savePreferences(pipeline, preferences.providers, preferences.typesetting)
150
+ .then((saved) => {
151
+ receivePreferences(saved)
152
+ })
153
+ .catch(() => undefined)
154
+ .finally(() => setSavingModel(null))
155
+ }
156
+
157
+ const chooseScope = (next: PipelineScope) => {
158
+ onScopeChange(next)
159
+ setView('root')
160
+ }
161
+
162
+ const saveOutput = (draft: OutputDraft) => {
163
+ if (!preferences || !translation || savingOutput) return
164
+ setSavingOutput(true)
165
+ const pipeline = {
166
+ ...preferences.pipeline,
167
+ translation: {
168
+ ...translation,
169
+ target_language: draft.targetLanguage,
170
+ instructions: draft.instructions || null,
171
+ },
172
+ }
173
+ void savePreferences(pipeline, preferences.providers, preferences.typesetting)
174
+ .then((saved) => {
175
+ receivePreferences(saved)
176
+ })
177
+ .catch(() => undefined)
178
+ .finally(() => setSavingOutput(false))
179
+ }
180
+
181
+ const toggleStage = (stage: Stage) => {
182
+ if (stages.includes(stage)) {
183
+ if (stages.length > 1) onStagesChange(stages.filter((candidate) => candidate !== stage))
184
+ return
185
+ }
186
+ onStagesChange(
187
+ pipelineStages.filter((candidate) => candidate === stage || stages.includes(candidate)),
188
+ )
189
+ }
190
+
191
+ return (
192
+ <Popover open={open} onOpenChange={handleOpenChange}>
193
+ <PopoverTrigger
194
+ type='button'
195
+ aria-label={t('inference.selector')}
196
+ className={cn(
197
+ 'flex h-7 max-w-52 items-center gap-1.5 rounded-lg bg-foreground/[0.05] px-2 text-[10px] text-muted-foreground transition-colors outline-none hover:bg-primary/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25 data-open:bg-primary/10 data-open:text-foreground',
198
+ running && 'text-foreground',
199
+ )}
200
+ >
201
+ {running ? (
202
+ <Sparkles className='size-3 shrink-0 text-primary' />
203
+ ) : (
204
+ <Bot className='size-3 shrink-0 text-primary' />
205
+ )}
206
+ <span className='min-w-0 truncate'>{modelLabel}</span>
207
+ <ChevronDown className='size-3 shrink-0' />
208
+ </PopoverTrigger>
209
+
210
+ <PopoverContent
211
+ align='start'
212
+ sideOffset={4}
213
+ className='w-64 min-w-0 gap-0 overflow-hidden rounded-xl border border-border/50 p-1 shadow-sm ring-0'
214
+ >
215
+ {view === 'root' && (
216
+ <div className='grid gap-0.5' aria-label={t('inference.shortcuts')}>
217
+ <SelectorRow
218
+ label={t('inference.model')}
219
+ value={modelLabel}
220
+ onClick={() => setView('model')}
221
+ />
222
+ <SelectorRow
223
+ label={t('inference.scope')}
224
+ value={t(`inference.scopeShort.${scope}`)}
225
+ onClick={() => setView('scope')}
226
+ />
227
+ <SelectorRow
228
+ label={t('inference.stages')}
229
+ value={
230
+ stages.length === 1
231
+ ? t(`phase.${stages[0]}`)
232
+ : t('inference.stageCount', { count: stages.length })
233
+ }
234
+ onClick={() => setView('stages')}
235
+ />
236
+ <SelectorRow
237
+ label={t('inference.output')}
238
+ value={outputLabel}
239
+ onClick={() => setView('output')}
240
+ />
241
+ <div className='my-1 border-t border-border/70' />
242
+ <Button
243
+ type='button'
244
+ variant='ghost'
245
+ size='sm'
246
+ className='h-8 justify-start gap-2 rounded-lg px-2 text-[11px] font-normal text-muted-foreground hover:bg-primary/10 hover:text-foreground'
247
+ onClick={() => {
248
+ setOpen(false)
249
+ setSettingsOpen(true)
250
+ }}
251
+ >
252
+ <Settings className='size-3.5' /> {t('menu.settings')}
253
+ </Button>
254
+ </div>
255
+ )}
256
+
257
+ {view === 'model' && (
258
+ <ModelPicker
259
+ value={model}
260
+ models={choices}
261
+ providers={providers}
262
+ loading={loadingModels}
263
+ disabled={running}
264
+ busyModel={savingModel}
265
+ onBack={() => setView('root')}
266
+ onSelect={chooseModel}
267
+ />
268
+ )}
269
+
270
+ {view === 'scope' && (
271
+ <SelectorPanel title={t('inference.scope')} onBack={() => setView('root')}>
272
+ <SelectorOption
273
+ value='page'
274
+ label={t('inference.currentPage')}
275
+ detail={t('inference.currentPageDescription')}
276
+ selected={scope === 'page'}
277
+ onSelect={chooseScope}
278
+ />
279
+ <SelectorOption
280
+ value='selected-pages'
281
+ label={t('inference.selectedPages')}
282
+ detail={
283
+ selectionCount
284
+ ? t('inference.selectedCount', { count: selectionCount })
285
+ : t('inference.selectPagesFirst')
286
+ }
287
+ selected={scope === 'selected-pages'}
288
+ disabled={selectionCount === 0}
289
+ onSelect={chooseScope}
290
+ />
291
+ <SelectorOption
292
+ value='project'
293
+ label={t('inference.entireProject')}
294
+ detail={t('inference.entireProjectDescription')}
295
+ selected={scope === 'project'}
296
+ onSelect={chooseScope}
297
+ />
298
+ </SelectorPanel>
299
+ )}
300
+
301
+ {view === 'stages' && (
302
+ <SelectorPanel title={t('inference.pipelineStages')} onBack={() => setView('root')}>
303
+ <SelectorOption
304
+ value='detection'
305
+ label={t('phase.detection')}
306
+ detail={t('phaseDescription.detection')}
307
+ selected={stages.includes('detection')}
308
+ onSelect={toggleStage}
309
+ />
310
+ <SelectorOption
311
+ value='ocr'
312
+ label={t('phase.ocr')}
313
+ detail={t('phaseDescription.ocr')}
314
+ selected={stages.includes('ocr')}
315
+ onSelect={toggleStage}
316
+ />
317
+ <SelectorOption
318
+ value='translation'
319
+ label={t('phase.translation')}
320
+ detail={t('phaseDescription.translation')}
321
+ selected={stages.includes('translation')}
322
+ onSelect={toggleStage}
323
+ />
324
+ <SelectorOption
325
+ value='inpainting'
326
+ label={t('phase.inpainting')}
327
+ detail={t('phaseDescription.inpainting')}
328
+ selected={stages.includes('inpainting')}
329
+ onSelect={toggleStage}
330
+ />
331
+ </SelectorPanel>
332
+ )}
333
+
334
+ {view === 'output' && translation && (
335
+ <OutputPicker
336
+ targetLanguage={translation.target_language}
337
+ instructions={translation.instructions}
338
+ languages={languages}
339
+ disabled={running}
340
+ saving={savingOutput}
341
+ onBack={() => setView('root')}
342
+ onChange={saveOutput}
343
+ />
344
+ )}
345
+ </PopoverContent>
346
+ </Popover>
347
+ )
348
+ }
349
+
350
+ function SelectorRow({
351
+ label,
352
+ value,
353
+ onClick,
354
+ }: {
355
+ label: string
356
+ value: string
357
+ onClick: () => void
358
+ }) {
359
+ return (
360
+ <Button
361
+ type='button'
362
+ variant='ghost'
363
+ size='sm'
364
+ aria-label={`${label} ${value}`}
365
+ className='h-8 min-w-0 justify-start gap-3 overflow-hidden rounded-lg px-2 text-[11px] font-normal hover:bg-primary/10'
366
+ onClick={onClick}
367
+ >
368
+ <span className='shrink-0'>{label}</span>
369
+ <span className='ml-auto min-w-0 flex-1 truncate text-right text-muted-foreground'>
370
+ {value}
371
+ </span>
372
+ <ChevronRight className='size-3.5 shrink-0 text-muted-foreground' />
373
+ </Button>
374
+ )
375
+ }
376
+
377
+ function SelectorPanel({
378
+ title,
379
+ onBack,
380
+ children,
381
+ }: {
382
+ title: string
383
+ onBack: () => void
384
+ children: ReactNode
385
+ }) {
386
+ const { t } = useTranslation()
387
+ return (
388
+ <div className='min-w-0 overflow-hidden'>
389
+ <div className='mb-1 flex h-7 items-center border-b border-border/60 px-0.5 pb-1'>
390
+ <Button
391
+ type='button'
392
+ variant='ghost'
393
+ size='icon-xs'
394
+ aria-label={t('common.back')}
395
+ className='rounded-md text-muted-foreground hover:bg-primary/10 hover:text-foreground'
396
+ onClick={onBack}
397
+ >
398
+ <ChevronLeft className='size-3.5' />
399
+ </Button>
400
+ <span className='ml-1 text-[11px] font-medium'>{title}</span>
401
+ </div>
402
+ <div className='grid min-w-0 gap-0.5 overflow-hidden'>{children}</div>
403
+ </div>
404
+ )
405
+ }
406
+
407
+ function SelectorOption<Value extends string>({
408
+ value,
409
+ label,
410
+ detail,
411
+ selected,
412
+ disabled = false,
413
+ onSelect,
414
+ }: {
415
+ value: Value
416
+ label: string
417
+ detail: string
418
+ selected: boolean
419
+ disabled?: boolean
420
+ onSelect: (value: Value) => void
421
+ }) {
422
+ return (
423
+ <Button
424
+ type='button'
425
+ variant='ghost'
426
+ aria-pressed={selected}
427
+ disabled={disabled}
428
+ className='h-auto min-h-9 justify-start gap-2 rounded-lg px-2 py-1 text-left font-normal hover:bg-primary/10'
429
+ onClick={() => onSelect(value)}
430
+ >
431
+ <span className='min-w-0 flex-1'>
432
+ <span className='block text-[11px]'>{label}</span>
433
+ <span className='block text-[9px] text-muted-foreground'>{detail}</span>
434
+ </span>
435
+ {selected && <Check className='size-3.5 shrink-0 text-primary' />}
436
+ </Button>
437
+ )
438
+ }
439
+
440
+ function availableModels(
441
+ selected: ModelSelection | null,
442
+ models: Model[],
443
+ providers: ProviderPreference[],
444
+ ): Model[] {
445
+ if (!selected || models.some((model) => modelKey(model) === modelKey(selected))) return models
446
+ return [
447
+ {
448
+ provider: selected.provider,
449
+ model: selected.model ?? null,
450
+ name: selected.model ?? providerName(providers, selected.provider),
451
+ quantizations: [],
452
+ vision: selected.vision ?? false,
453
+ reasoning: selected.reasoning ?? false,
454
+ },
455
+ ...models,
456
+ ]
457
+ }