@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.
- package/README.md +37 -2
- package/package.json +70 -4
- package/src/assets/NotoSansCJK-VF.otf +0 -0
- package/src/assets/OFL.txt +92 -0
- package/src/components/ClientOnly.tsx +21 -0
- package/src/components/ShenronExportPanel.tsx +213 -0
- package/src/components/app/AboutDialog.tsx +64 -0
- package/src/components/app/KoharuApp.tsx +58 -0
- package/src/components/app/StartupView.tsx +67 -0
- package/src/components/app/TitleBar.tsx +322 -0
- package/src/components/app/Updater.tsx +143 -0
- package/src/components/app/WindowChrome.tsx +137 -0
- package/src/components/controls/ColorSampling.tsx +70 -0
- package/src/components/controls/ColorWell.tsx +139 -0
- package/src/components/controls/CommitTextarea.tsx +63 -0
- package/src/components/controls/FontPicker.tsx +543 -0
- package/src/components/controls/ModelPicker.tsx +178 -0
- package/src/components/controls/OutputPicker.tsx +170 -0
- package/src/components/editor/ActivityCenter.tsx +234 -0
- package/src/components/editor/AgentPanel.tsx +352 -0
- package/src/components/editor/CanvasCommandBar.tsx +38 -0
- package/src/components/editor/CanvasOverlay.tsx +194 -0
- package/src/components/editor/CanvasWorkspace.tsx +947 -0
- package/src/components/editor/Editor.tsx +56 -0
- package/src/components/editor/InferenceControl.tsx +457 -0
- package/src/components/editor/Inspector.tsx +1073 -0
- package/src/components/editor/PageRail.tsx +581 -0
- package/src/components/editor/ResourceMonitor.tsx +151 -0
- package/src/components/editor/RightSidebar.tsx +50 -0
- package/src/components/editor/SelectionControls.tsx +184 -0
- package/src/components/editor/StatusBar.tsx +41 -0
- package/src/components/editor/ToolBar.tsx +182 -0
- package/src/components/editor/useCanvas.ts +194 -0
- package/src/components/preferences/GenerationPreferences.tsx +118 -0
- package/src/components/preferences/KoharuSettings.tsx +229 -0
- package/src/components/preferences/PipelinePreferences.tsx +173 -0
- package/src/components/preferences/PreferenceFields.tsx +149 -0
- package/src/components/preferences/ProviderPreferences.tsx +173 -0
- package/src/components/preferences/SettingsPage.tsx +336 -0
- package/src/components/preferences/TranslationPreferences.tsx +187 -0
- package/src/components/preferences/TypesettingPreferences.tsx +142 -0
- package/src/components/preferences/models.ts +103 -0
- package/src/components/start/StartView.tsx +277 -0
- package/src/index.ts +59 -0
- package/src/internal/components/accordion.tsx +72 -0
- package/src/internal/components/alert-dialog.tsx +189 -0
- package/src/internal/components/alert.tsx +76 -0
- package/src/internal/components/aspect-ratio.tsx +22 -0
- package/src/internal/components/attachment.tsx +207 -0
- package/src/internal/components/avatar.tsx +109 -0
- package/src/internal/components/badge.tsx +52 -0
- package/src/internal/components/breadcrumb.tsx +125 -0
- package/src/internal/components/bubble.tsx +128 -0
- package/src/internal/components/button-group.tsx +87 -0
- package/src/internal/components/button.tsx +58 -0
- package/src/internal/components/card.tsx +103 -0
- package/src/internal/components/checkbox.tsx +29 -0
- package/src/internal/components/collapsible.tsx +21 -0
- package/src/internal/components/combobox.tsx +298 -0
- package/src/internal/components/context-menu.tsx +272 -0
- package/src/internal/components/dialog.tsx +162 -0
- package/src/internal/components/direction.tsx +6 -0
- package/src/internal/components/drawer.tsx +230 -0
- package/src/internal/components/dropdown-menu.tsx +278 -0
- package/src/internal/components/empty.tsx +104 -0
- package/src/internal/components/field.tsx +238 -0
- package/src/internal/components/hover-card.tsx +52 -0
- package/src/internal/components/input-group.tsx +158 -0
- package/src/internal/components/input.tsx +20 -0
- package/src/internal/components/item.tsx +201 -0
- package/src/internal/components/kbd.tsx +26 -0
- package/src/internal/components/label.tsx +20 -0
- package/src/internal/components/marker.tsx +71 -0
- package/src/internal/components/menubar.tsx +280 -0
- package/src/internal/components/message.tsx +92 -0
- package/src/internal/components/native-select.tsx +61 -0
- package/src/internal/components/navigation-menu.tsx +168 -0
- package/src/internal/components/number-field.tsx +68 -0
- package/src/internal/components/pagination.tsx +130 -0
- package/src/internal/components/popover.tsx +91 -0
- package/src/internal/components/progress.tsx +83 -0
- package/src/internal/components/radio-group.tsx +38 -0
- package/src/internal/components/resizable.tsx +50 -0
- package/src/internal/components/scroll-area.tsx +88 -0
- package/src/internal/components/select.tsx +191 -0
- package/src/internal/components/separator.tsx +25 -0
- package/src/internal/components/sheet.tsx +140 -0
- package/src/internal/components/sidebar.tsx +723 -0
- package/src/internal/components/skeleton.tsx +13 -0
- package/src/internal/components/slider.tsx +56 -0
- package/src/internal/components/spinner.tsx +10 -0
- package/src/internal/components/switch.tsx +32 -0
- package/src/internal/components/table.tsx +116 -0
- package/src/internal/components/tabs.tsx +82 -0
- package/src/internal/components/textarea.tsx +18 -0
- package/src/internal/components/toast.tsx +235 -0
- package/src/internal/components/toggle-group.tsx +89 -0
- package/src/internal/components/toggle.tsx +45 -0
- package/src/internal/components/tooltip.tsx +67 -0
- package/src/internal/hooks/use-mobile.ts +19 -0
- package/src/internal/lib/utils.ts +6 -0
- package/src/internal/styles/globals.css +73 -0
- package/src/lib/backend.ts +85 -0
- package/src/lib/document.ts +46 -0
- package/src/lib/geometry.ts +272 -0
- package/src/lib/i18n.ts +54 -0
- package/src/lib/queries.ts +110 -0
- package/src/lib/store.ts +191 -0
- package/src/lib/translation.ts +35 -0
- package/src/locales/en-US/translation.json +536 -0
- package/src/locales/es-ES/translation.json +536 -0
- package/src/locales/ja-JP/translation.json +536 -0
- package/src/locales/ko-KR/translation.json +536 -0
- package/src/locales/pt-BR/translation.json +536 -0
- package/src/locales/ru-RU/translation.json +536 -0
- package/src/locales/tr-TR/translation.json +536 -0
- package/src/locales/zh-CN/translation.json +536 -0
- package/src/locales/zh-TW/translation.json +536 -0
- package/src/navigation.ts +28 -0
- package/src/providers.tsx +132 -0
- package/src/shenron-pipeline-env.d.ts +14 -0
- package/src/styles.css +246 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Check, ChevronLeft, ChevronRight, HardDrive, LoaderCircle, Search, X } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
|
|
7
|
+
import { modelKey, modelSelection, providerName } from '@aphrody/koharu-ui/lib/translation'
|
|
8
|
+
import type { Model, ModelSelection, ProviderPreference } from '@aphrody/koharu-bridge/protocol'
|
|
9
|
+
import { Button } from '../../internal/components/button'
|
|
10
|
+
import {
|
|
11
|
+
InputGroup,
|
|
12
|
+
InputGroupAddon,
|
|
13
|
+
InputGroupButton,
|
|
14
|
+
InputGroupInput,
|
|
15
|
+
} from '../../internal/components/input-group'
|
|
16
|
+
import { ScrollArea } from '../../internal/components/scroll-area'
|
|
17
|
+
|
|
18
|
+
export function ModelPicker({
|
|
19
|
+
value,
|
|
20
|
+
models,
|
|
21
|
+
providers,
|
|
22
|
+
loading = false,
|
|
23
|
+
disabled = false,
|
|
24
|
+
busyModel,
|
|
25
|
+
onBack,
|
|
26
|
+
onSelect,
|
|
27
|
+
}: {
|
|
28
|
+
value: ModelSelection | null
|
|
29
|
+
models: Model[]
|
|
30
|
+
providers: ProviderPreference[]
|
|
31
|
+
loading?: boolean
|
|
32
|
+
disabled?: boolean
|
|
33
|
+
busyModel?: string | null
|
|
34
|
+
onBack: () => void
|
|
35
|
+
onSelect: (model: ModelSelection) => void
|
|
36
|
+
}) {
|
|
37
|
+
const { t } = useTranslation()
|
|
38
|
+
const [query, setQuery] = useState('')
|
|
39
|
+
const [pendingKey, setPendingKey] = useState<string | null>(null)
|
|
40
|
+
const pendingModel = models.find((model) => modelKey(model) === pendingKey)
|
|
41
|
+
const selectedKey = value ? modelKey(value) : null
|
|
42
|
+
const saving = Boolean(busyModel)
|
|
43
|
+
const normalizedQuery = query.trim().toLocaleLowerCase()
|
|
44
|
+
const options = pendingModel
|
|
45
|
+
? pendingModel.quantizations.map((quantization) => ({ model: pendingModel, quantization }))
|
|
46
|
+
: models
|
|
47
|
+
.filter((model) => {
|
|
48
|
+
if (!normalizedQuery) return true
|
|
49
|
+
return [model.name, model.model, providerName(providers, model.provider)].some(
|
|
50
|
+
(candidate) => candidate?.toLocaleLowerCase().includes(normalizedQuery),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
.map((model) => ({ model, quantization: null }))
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className='min-w-0 overflow-hidden'>
|
|
57
|
+
<div className='mb-1 flex h-7 items-center border-b border-border/60 px-0.5 pb-1'>
|
|
58
|
+
<Button
|
|
59
|
+
type='button'
|
|
60
|
+
variant='ghost'
|
|
61
|
+
size='icon-xs'
|
|
62
|
+
aria-label={t('common.back')}
|
|
63
|
+
className='rounded-md text-muted-foreground hover:bg-primary/10 hover:text-foreground'
|
|
64
|
+
disabled={saving}
|
|
65
|
+
onClick={() => (pendingModel ? setPendingKey(null) : onBack())}
|
|
66
|
+
>
|
|
67
|
+
<ChevronLeft className='size-3.5' />
|
|
68
|
+
</Button>
|
|
69
|
+
<span className='ml-1 min-w-0 flex-1 truncate text-[11px] font-medium'>
|
|
70
|
+
{pendingModel?.name ?? t('modelPicker.title')}
|
|
71
|
+
</span>
|
|
72
|
+
{pendingModel && busyModel === pendingKey && (
|
|
73
|
+
<LoaderCircle className='mr-1 size-3.5 shrink-0 animate-spin text-primary' />
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{pendingModel ? (
|
|
78
|
+
<div className='border-b border-border/60 px-2 py-1.5 text-[11px] text-muted-foreground'>
|
|
79
|
+
{t('settings.translation.selectQuantization')}
|
|
80
|
+
</div>
|
|
81
|
+
) : (
|
|
82
|
+
<div className='border-b border-border/60 p-1'>
|
|
83
|
+
<InputGroup className='h-7 border-0 bg-muted/50 shadow-none focus-within:bg-background has-[>[data-align=inline-start]]:[&>input]:pl-0'>
|
|
84
|
+
<InputGroupAddon className='pr-1 pl-1.5'>
|
|
85
|
+
<Search className='size-3' />
|
|
86
|
+
</InputGroupAddon>
|
|
87
|
+
<InputGroupInput
|
|
88
|
+
|
|
89
|
+
value={query}
|
|
90
|
+
aria-label={t('modelPicker.search')}
|
|
91
|
+
placeholder={t('modelPicker.search')}
|
|
92
|
+
className='h-7 px-0 text-[11px]'
|
|
93
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
94
|
+
/>
|
|
95
|
+
{query && (
|
|
96
|
+
<InputGroupAddon align='inline-end' className='pr-0.5'>
|
|
97
|
+
<InputGroupButton aria-label={t('common.clearSearch')} onClick={() => setQuery('')}>
|
|
98
|
+
<X />
|
|
99
|
+
</InputGroupButton>
|
|
100
|
+
</InputGroupAddon>
|
|
101
|
+
)}
|
|
102
|
+
</InputGroup>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<ScrollArea
|
|
107
|
+
key={pendingKey}
|
|
108
|
+
className='max-h-64 min-w-0 overflow-hidden'
|
|
109
|
+
viewportClassName='h-auto max-h-64 min-w-0 overscroll-contain'
|
|
110
|
+
>
|
|
111
|
+
<div className='grid min-w-0 gap-0.5 py-0.5'>
|
|
112
|
+
{options.map(({ model, quantization }, index) => {
|
|
113
|
+
const key = modelKey(model)
|
|
114
|
+
const expandable = !quantization && model.quantizations.length > 0
|
|
115
|
+
const downloaded =
|
|
116
|
+
quantization?.downloaded ??
|
|
117
|
+
model.quantizations.some((candidate) => candidate.downloaded)
|
|
118
|
+
const selected =
|
|
119
|
+
key === selectedKey &&
|
|
120
|
+
(!quantization ||
|
|
121
|
+
quantization.id === (value?.quantization ?? model.quantizations[0]?.id))
|
|
122
|
+
const provider = providerName(providers, model.provider)
|
|
123
|
+
return (
|
|
124
|
+
<Button
|
|
125
|
+
key={quantization?.id ?? key}
|
|
126
|
+
|
|
127
|
+
type='button'
|
|
128
|
+
variant='ghost'
|
|
129
|
+
aria-label={
|
|
130
|
+
quantization
|
|
131
|
+
? undefined
|
|
132
|
+
: t('modelPicker.useModel', { model: model.name, provider })
|
|
133
|
+
}
|
|
134
|
+
aria-pressed={selected}
|
|
135
|
+
disabled={disabled || saving}
|
|
136
|
+
className='h-auto min-h-9 w-full max-w-full min-w-0 justify-start gap-2 overflow-hidden rounded-lg px-2 py-1 text-left font-normal hover:bg-primary/10'
|
|
137
|
+
onClick={() => {
|
|
138
|
+
if (expandable) setPendingKey(key)
|
|
139
|
+
else onSelect(modelSelection(model, quantization?.id ?? null))
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<span className='min-w-0 flex-1 overflow-hidden'>
|
|
143
|
+
<span className='block truncate text-[11px] text-foreground'>
|
|
144
|
+
{quantization?.name ?? model.name}
|
|
145
|
+
</span>
|
|
146
|
+
{!quantization && (
|
|
147
|
+
<span className='block truncate text-[9px] text-muted-foreground'>
|
|
148
|
+
{provider}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</span>
|
|
152
|
+
{downloaded && <HardDrive className='size-3.5 shrink-0 text-muted-foreground' />}
|
|
153
|
+
{!quantization && busyModel === key ? (
|
|
154
|
+
<LoaderCircle className='size-3.5 shrink-0 animate-spin text-primary' />
|
|
155
|
+
) : (
|
|
156
|
+
selected && <Check className='size-3.5 shrink-0 text-primary' />
|
|
157
|
+
)}
|
|
158
|
+
{expandable && <ChevronRight className='size-3.5 shrink-0 text-muted-foreground' />}
|
|
159
|
+
</Button>
|
|
160
|
+
)
|
|
161
|
+
})}
|
|
162
|
+
{options.length === 0 && (
|
|
163
|
+
<div className='flex items-center justify-center gap-2 px-2.5 py-5 text-center text-[11px] text-muted-foreground'>
|
|
164
|
+
{loading && models.length === 0 && <LoaderCircle className='size-3.5 animate-spin' />}
|
|
165
|
+
{t(
|
|
166
|
+
models.length > 0
|
|
167
|
+
? 'modelPicker.noResults'
|
|
168
|
+
: loading
|
|
169
|
+
? 'modelPicker.loading'
|
|
170
|
+
: 'modelPicker.empty',
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</ScrollArea>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ChevronLeft } from 'lucide-react'
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
|
|
7
|
+
import { orderedLanguageChoices } from '@aphrody/koharu-ui/lib/translation'
|
|
8
|
+
import type { LanguageChoice } from '@aphrody/koharu-bridge/protocol'
|
|
9
|
+
import { Button } from '../../internal/components/button'
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from '../../internal/components/select'
|
|
17
|
+
import { Textarea } from '../../internal/components/textarea'
|
|
18
|
+
|
|
19
|
+
export type OutputDraft = {
|
|
20
|
+
targetLanguage: string
|
|
21
|
+
instructions: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function OutputPicker({
|
|
25
|
+
targetLanguage,
|
|
26
|
+
instructions,
|
|
27
|
+
languages,
|
|
28
|
+
disabled = false,
|
|
29
|
+
saving = false,
|
|
30
|
+
onBack,
|
|
31
|
+
onChange,
|
|
32
|
+
}: {
|
|
33
|
+
targetLanguage: string
|
|
34
|
+
instructions: string | null
|
|
35
|
+
languages: LanguageChoice[]
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
saving?: boolean
|
|
38
|
+
onBack: () => void
|
|
39
|
+
onChange: (draft: OutputDraft) => void
|
|
40
|
+
}) {
|
|
41
|
+
const { t } = useTranslation()
|
|
42
|
+
const [draft, setDraft] = useState<OutputDraft>({
|
|
43
|
+
targetLanguage,
|
|
44
|
+
instructions: instructions ?? '',
|
|
45
|
+
})
|
|
46
|
+
const changed =
|
|
47
|
+
draft.targetLanguage !== targetLanguage || draft.instructions !== (instructions ?? '')
|
|
48
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
49
|
+
const latest = useRef({ changed, disabled, draft, onChange, saving })
|
|
50
|
+
const submitted = useRef<OutputDraft | null>(null)
|
|
51
|
+
latest.current = { changed, disabled, draft, onChange, saving }
|
|
52
|
+
const languageChoices = useMemo(() => orderedLanguageChoices(languages), [languages])
|
|
53
|
+
|
|
54
|
+
const submit = useCallback(
|
|
55
|
+
(next: OutputDraft) => {
|
|
56
|
+
submitted.current = next
|
|
57
|
+
onChange(next)
|
|
58
|
+
},
|
|
59
|
+
[onChange],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!changed || saving || disabled || !draft.targetLanguage) return
|
|
64
|
+
saveTimer.current = setTimeout(() => submit(draft), 350)
|
|
65
|
+
return () => {
|
|
66
|
+
clearTimeout(saveTimer.current)
|
|
67
|
+
saveTimer.current = undefined
|
|
68
|
+
}
|
|
69
|
+
}, [changed, disabled, draft, saving, submit])
|
|
70
|
+
|
|
71
|
+
useEffect(
|
|
72
|
+
() => () => {
|
|
73
|
+
clearTimeout(saveTimer.current)
|
|
74
|
+
const current = latest.current
|
|
75
|
+
if (
|
|
76
|
+
current.changed &&
|
|
77
|
+
!current.saving &&
|
|
78
|
+
!current.disabled &&
|
|
79
|
+
current.draft.targetLanguage &&
|
|
80
|
+
!sameDraft(submitted.current, current.draft)
|
|
81
|
+
) {
|
|
82
|
+
current.onChange(current.draft)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
[],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const back = () => {
|
|
89
|
+
clearTimeout(saveTimer.current)
|
|
90
|
+
saveTimer.current = undefined
|
|
91
|
+
if (
|
|
92
|
+
changed &&
|
|
93
|
+
!saving &&
|
|
94
|
+
!disabled &&
|
|
95
|
+
draft.targetLanguage &&
|
|
96
|
+
!sameDraft(submitted.current, draft)
|
|
97
|
+
) {
|
|
98
|
+
submit(draft)
|
|
99
|
+
}
|
|
100
|
+
onBack()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className='min-w-0 overflow-hidden'>
|
|
105
|
+
<div className='mb-1 flex h-7 items-center border-b border-border/60 px-0.5 pb-1'>
|
|
106
|
+
<Button
|
|
107
|
+
type='button'
|
|
108
|
+
variant='ghost'
|
|
109
|
+
size='icon-xs'
|
|
110
|
+
aria-label={t('common.back')}
|
|
111
|
+
className='rounded-md text-muted-foreground hover:bg-primary/10 hover:text-foreground'
|
|
112
|
+
disabled={saving}
|
|
113
|
+
onClick={back}
|
|
114
|
+
>
|
|
115
|
+
<ChevronLeft className='size-3.5' />
|
|
116
|
+
</Button>
|
|
117
|
+
<span className='ml-1 text-[11px] font-medium'>{t('outputPicker.title')}</span>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className='grid gap-2 p-1'>
|
|
121
|
+
<label className='grid gap-1 text-[9px] text-muted-foreground'>
|
|
122
|
+
{t('model.targetLanguage')}
|
|
123
|
+
<Select
|
|
124
|
+
value={draft.targetLanguage}
|
|
125
|
+
items={Object.fromEntries(
|
|
126
|
+
languageChoices.map((language) => [language.tag, language.name]),
|
|
127
|
+
)}
|
|
128
|
+
disabled={disabled}
|
|
129
|
+
onValueChange={(targetLanguage) =>
|
|
130
|
+
targetLanguage && setDraft((current) => ({ ...current, targetLanguage }))
|
|
131
|
+
}
|
|
132
|
+
>
|
|
133
|
+
<SelectTrigger
|
|
134
|
+
aria-label={t('outputPicker.targetLanguage')}
|
|
135
|
+
className='h-7 w-full text-[11px]'
|
|
136
|
+
>
|
|
137
|
+
<SelectValue />
|
|
138
|
+
</SelectTrigger>
|
|
139
|
+
<SelectContent>
|
|
140
|
+
{languageChoices.map((language) => (
|
|
141
|
+
<SelectItem key={language.tag} value={language.tag}>
|
|
142
|
+
{language.name}
|
|
143
|
+
</SelectItem>
|
|
144
|
+
))}
|
|
145
|
+
</SelectContent>
|
|
146
|
+
</Select>
|
|
147
|
+
</label>
|
|
148
|
+
|
|
149
|
+
<label className='grid gap-1 text-[9px] text-muted-foreground'>
|
|
150
|
+
{t('model.instructions')}
|
|
151
|
+
<Textarea
|
|
152
|
+
value={draft.instructions}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
aria-label={t('outputPicker.instructions')}
|
|
155
|
+
placeholder={t('outputPicker.instructionsPlaceholder')}
|
|
156
|
+
className='max-h-20 min-h-20 resize-none overflow-y-auto text-[11px] leading-4'
|
|
157
|
+
onChange={(event) => {
|
|
158
|
+
const instructions = event.currentTarget.value
|
|
159
|
+
setDraft((current) => ({ ...current, instructions }))
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sameDraft(left: OutputDraft | null, right: OutputDraft): boolean {
|
|
169
|
+
return left?.targetLanguage === right.targetLanguage && left.instructions === right.instructions
|
|
170
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMutationState } from '@tanstack/react-query'
|
|
4
|
+
import { CircleAlert, Download, LoaderCircle, Square, X } from 'lucide-react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
|
|
7
|
+
import { call } from '@aphrody/koharu-ui/lib/backend'
|
|
8
|
+
import { usePages } from '@aphrody/koharu-ui/lib/queries'
|
|
9
|
+
import { useKoharuStore } from '@aphrody/koharu-ui/lib/store'
|
|
10
|
+
import { commands, type Download as DownloadState, type Job } from '@aphrody/koharu-bridge/protocol'
|
|
11
|
+
import { Button } from '../../internal/components/button'
|
|
12
|
+
|
|
13
|
+
export function ActivityCenter() {
|
|
14
|
+
const { t } = useTranslation()
|
|
15
|
+
const jobs = useKoharuStore((state) => state.jobs)
|
|
16
|
+
const downloads = useKoharuStore((state) => state.downloads)
|
|
17
|
+
const activities = useMutationState({
|
|
18
|
+
filters: { status: 'pending' },
|
|
19
|
+
select: (mutation) => ({
|
|
20
|
+
id: mutation.mutationId,
|
|
21
|
+
label: mutation.options.meta?.activity,
|
|
22
|
+
}),
|
|
23
|
+
}).filter(
|
|
24
|
+
(activity): activity is { id: number; label: string } => typeof activity.label === 'string',
|
|
25
|
+
)
|
|
26
|
+
const visibleJobs = Object.values(jobs).filter(
|
|
27
|
+
(job) => job.state === 'running' || job.state === 'failed',
|
|
28
|
+
)
|
|
29
|
+
const runningDownloads = Object.values(downloads).filter(
|
|
30
|
+
(download) => download.state === 'running',
|
|
31
|
+
)
|
|
32
|
+
const failedDownloads = Object.values(downloads).filter((download) => download.state === 'failed')
|
|
33
|
+
const visibleDownloads = [...runningDownloads, ...failedDownloads]
|
|
34
|
+
if (activities.length === 0 && visibleJobs.length === 0 && visibleDownloads.length === 0)
|
|
35
|
+
return null
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<aside
|
|
39
|
+
aria-label={t('activity.title')}
|
|
40
|
+
className='absolute right-3 bottom-9 z-30 flex w-72 max-w-[calc(100%-1.5rem)] flex-col rounded-2xl border border-border/50 bg-popover shadow-md'
|
|
41
|
+
>
|
|
42
|
+
<div className='border-b px-3 py-2'>
|
|
43
|
+
<span className='text-[10px] font-semibold tracking-[0.1em] uppercase'>
|
|
44
|
+
{t('activity.title')}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
{activities.map((activity) => (
|
|
48
|
+
<div
|
|
49
|
+
key={activity.id}
|
|
50
|
+
role='status'
|
|
51
|
+
className='grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-x-2.5 border-b p-3 last:border-b-0'
|
|
52
|
+
>
|
|
53
|
+
<LoaderCircle
|
|
54
|
+
className='size-3.5 animate-spin justify-self-center text-primary motion-reduce:animate-none'
|
|
55
|
+
aria-hidden='true'
|
|
56
|
+
/>
|
|
57
|
+
<span className='truncate text-[12px] font-medium'>{t(activity.label)}</span>
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
{runningDownloads.length > 1 ? (
|
|
61
|
+
<DownloadGroup downloads={runningDownloads} />
|
|
62
|
+
) : (
|
|
63
|
+
runningDownloads.map((download) => <DownloadItem key={download.id} download={download} />)
|
|
64
|
+
)}
|
|
65
|
+
{failedDownloads.map((download) => (
|
|
66
|
+
<DownloadItem key={download.id} download={download} />
|
|
67
|
+
))}
|
|
68
|
+
{visibleJobs.map((job) => (
|
|
69
|
+
<JobItem key={job.id} job={job} />
|
|
70
|
+
))}
|
|
71
|
+
</aside>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function DownloadGroup({ downloads }: { downloads: DownloadState[] }) {
|
|
76
|
+
const { t } = useTranslation()
|
|
77
|
+
const hasKnownTotal = downloads.every((download) => download.total > 0)
|
|
78
|
+
const percent = hasKnownTotal
|
|
79
|
+
? progress(
|
|
80
|
+
downloads.reduce((sum, download) => sum + download.completed, 0),
|
|
81
|
+
downloads.reduce((sum, download) => sum + download.total, 0),
|
|
82
|
+
)
|
|
83
|
+
: null
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className='border-b p-3 last:border-b-0'>
|
|
87
|
+
<div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
|
|
88
|
+
<Download className='mt-0.5 size-3.5 justify-self-center text-primary' />
|
|
89
|
+
<span className='truncate text-[11px]'>
|
|
90
|
+
{t('activity.downloadingFiles', { count: downloads.length })}
|
|
91
|
+
</span>
|
|
92
|
+
<span className='text-right text-[10px] tabular-nums'>
|
|
93
|
+
{percent !== null ? `${percent}%` : null}
|
|
94
|
+
</span>
|
|
95
|
+
<span aria-hidden='true' />
|
|
96
|
+
<div className='col-start-2 col-end-4'>
|
|
97
|
+
<Progress value={percent} />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function JobItem({ job }: { job: Job }) {
|
|
105
|
+
const { t } = useTranslation()
|
|
106
|
+
const dismiss = useKoharuStore((state) => state.dismissJob)
|
|
107
|
+
// The pipeline reports which page it is on; the rail has already loaded the
|
|
108
|
+
// labels, so this resolves from cache rather than fetching.
|
|
109
|
+
const pages = usePages(job.page !== null).data
|
|
110
|
+
// Numbered by position in the rail, so the row says where in the run this
|
|
111
|
+
// is as well as which file: a scan filename alone rarely tells you.
|
|
112
|
+
const pageIndex = job.page ? (pages?.findIndex((page) => page.id === job.page) ?? -1) : -1
|
|
113
|
+
const page = pageIndex >= 0 ? pages?.[pageIndex] : undefined
|
|
114
|
+
const pageLabel = page
|
|
115
|
+
? t('activity.pageLabel', { number: pageIndex + 1, label: page.label })
|
|
116
|
+
: undefined
|
|
117
|
+
if (job.state === 'failed') {
|
|
118
|
+
return (
|
|
119
|
+
<Failure
|
|
120
|
+
message={job.error || t('activity.processingFailed')}
|
|
121
|
+
onDismiss={() => dismiss(job.id)}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const percent = progress(job.completed, job.total)
|
|
126
|
+
return (
|
|
127
|
+
<div className='border-b p-3 last:border-b-0'>
|
|
128
|
+
<div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
|
|
129
|
+
<span className='mt-1.5 size-1.5 justify-self-center rounded-full bg-primary' />
|
|
130
|
+
<div className='min-w-0'>
|
|
131
|
+
<span className='block truncate text-[12px] font-medium capitalize'>
|
|
132
|
+
{job.stage
|
|
133
|
+
? t(`phase.${job.stage}`, { defaultValue: job.stage })
|
|
134
|
+
: t('activity.processing')}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<span className='pt-0.5 text-right text-[10px] tabular-nums'>
|
|
138
|
+
{percent !== null ? `${percent}%` : null}
|
|
139
|
+
</span>
|
|
140
|
+
<Button
|
|
141
|
+
size='icon-xs'
|
|
142
|
+
variant='ghost'
|
|
143
|
+
className='-mt-1'
|
|
144
|
+
aria-label={t('activity.stop')}
|
|
145
|
+
onClick={() => void call(commands.stopJob, job.id).catch(() => undefined)}
|
|
146
|
+
>
|
|
147
|
+
<Square className='size-2.5 fill-current' />
|
|
148
|
+
</Button>
|
|
149
|
+
{pageLabel ? (
|
|
150
|
+
<p className='col-start-2 col-end-4 mt-0.5 truncate text-[10px] text-muted-foreground'>
|
|
151
|
+
{pageLabel}
|
|
152
|
+
</p>
|
|
153
|
+
) : null}
|
|
154
|
+
{job.model ? (
|
|
155
|
+
<p className='col-start-2 col-end-4 truncate text-[10px] text-muted-foreground'>
|
|
156
|
+
{job.model}
|
|
157
|
+
</p>
|
|
158
|
+
) : null}
|
|
159
|
+
<div className='col-start-2 col-end-4'>
|
|
160
|
+
<Progress value={percent} />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function DownloadItem({ download }: { download: DownloadState }) {
|
|
168
|
+
const { t } = useTranslation()
|
|
169
|
+
const dismiss = useKoharuStore((state) => state.dismissDownload)
|
|
170
|
+
if (download.state === 'failed') {
|
|
171
|
+
return (
|
|
172
|
+
<Failure
|
|
173
|
+
message={download.error || t('activity.downloadFailed')}
|
|
174
|
+
onDismiss={() => dismiss(download.id)}
|
|
175
|
+
/>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
const percent = progress(download.completed, download.total)
|
|
179
|
+
return (
|
|
180
|
+
<div className='border-b p-3 last:border-b-0'>
|
|
181
|
+
<div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5'>
|
|
182
|
+
<Download className='mt-0.5 size-3.5 justify-self-center text-primary' />
|
|
183
|
+
<span className='truncate text-[11px]'>{download.name || t('activity.modelDownload')}</span>
|
|
184
|
+
<span className='text-right text-[10px] tabular-nums'>
|
|
185
|
+
{percent !== null ? `${percent}%` : null}
|
|
186
|
+
</span>
|
|
187
|
+
<span aria-hidden='true' />
|
|
188
|
+
<div className='col-start-2 col-end-4'>
|
|
189
|
+
<Progress value={percent} />
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function Failure({ message, onDismiss }: { message: string; onDismiss: () => void }) {
|
|
197
|
+
const { t } = useTranslation()
|
|
198
|
+
return (
|
|
199
|
+
<div className='grid grid-cols-[1rem_minmax(0,1fr)_2.25rem_1.5rem] items-start gap-x-2.5 border-b p-3 text-[11px] last:border-b-0'>
|
|
200
|
+
<CircleAlert className='mt-0.5 size-3.5 justify-self-center text-destructive' />
|
|
201
|
+
<span className='col-start-2 col-end-4 min-w-0 text-destructive'>{message}</span>
|
|
202
|
+
<Button
|
|
203
|
+
size='icon-xs'
|
|
204
|
+
variant='ghost'
|
|
205
|
+
className='-mt-1'
|
|
206
|
+
aria-label={t('activity.dismiss')}
|
|
207
|
+
onClick={onDismiss}
|
|
208
|
+
>
|
|
209
|
+
<X />
|
|
210
|
+
</Button>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function Progress({ value }: { value: number | null }) {
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
role='progressbar'
|
|
219
|
+
aria-valuemin={0}
|
|
220
|
+
aria-valuemax={100}
|
|
221
|
+
aria-valuenow={value ?? undefined}
|
|
222
|
+
className='mt-2 h-1 overflow-hidden rounded-full bg-muted'
|
|
223
|
+
>
|
|
224
|
+
<div
|
|
225
|
+
className={`h-full rounded-full bg-primary ${value === null ? 'w-1/2' : ''}`}
|
|
226
|
+
style={value === null ? undefined : { width: `${value}%` }}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function progress(completed: number, total: number): number | null {
|
|
233
|
+
return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : null
|
|
234
|
+
}
|