@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,173 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Eraser, FileText, Search } from 'lucide-react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
defaultModel,
|
|
8
|
+
modelNames,
|
|
9
|
+
modelOptions,
|
|
10
|
+
replaceStage,
|
|
11
|
+
stageModel,
|
|
12
|
+
type ModelName,
|
|
13
|
+
type ModelStage,
|
|
14
|
+
type PipelineModel,
|
|
15
|
+
} from '@aphrody/koharu-ui/components/preferences/models'
|
|
16
|
+
import {
|
|
17
|
+
NumberField,
|
|
18
|
+
PreferencePage,
|
|
19
|
+
PreferenceRow,
|
|
20
|
+
PreferenceSection,
|
|
21
|
+
TextField,
|
|
22
|
+
} from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
|
|
23
|
+
import type { PipelineConfig } from '@aphrody/koharu-bridge/protocol'
|
|
24
|
+
import {
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
} from '../../internal/components/select'
|
|
31
|
+
|
|
32
|
+
const stages = [
|
|
33
|
+
['detection', Search],
|
|
34
|
+
['ocr', FileText],
|
|
35
|
+
['inpainting', Eraser],
|
|
36
|
+
] as const satisfies ReadonlyArray<readonly [ModelStage, typeof Search]>
|
|
37
|
+
|
|
38
|
+
export function PipelinePreferences({
|
|
39
|
+
value,
|
|
40
|
+
onChange,
|
|
41
|
+
}: {
|
|
42
|
+
value: PipelineConfig
|
|
43
|
+
onChange: (value: PipelineConfig) => void
|
|
44
|
+
}) {
|
|
45
|
+
const { t } = useTranslation()
|
|
46
|
+
return (
|
|
47
|
+
<PreferencePage
|
|
48
|
+
title={t('settings.pipeline.title')}
|
|
49
|
+
description={t('settings.pipeline.description')}
|
|
50
|
+
>
|
|
51
|
+
<PreferenceSection title={t('settings.pipeline.processing')}>
|
|
52
|
+
{stages.map(([stage, Icon]) => {
|
|
53
|
+
const model = stageModel(value, stage)
|
|
54
|
+
const title = t(`settings.pipeline.stages.${stage}.title`)
|
|
55
|
+
return (
|
|
56
|
+
<PreferenceRow
|
|
57
|
+
key={stage}
|
|
58
|
+
title={title}
|
|
59
|
+
description={t(`settings.pipeline.stages.${stage}.description`)}
|
|
60
|
+
align='start'
|
|
61
|
+
>
|
|
62
|
+
<div className='grid gap-3'>
|
|
63
|
+
<div className='flex items-center gap-2'>
|
|
64
|
+
<Icon className='size-3.5 shrink-0 text-muted-foreground' />
|
|
65
|
+
<Select
|
|
66
|
+
value={model.model}
|
|
67
|
+
items={Object.fromEntries(
|
|
68
|
+
modelOptions[stage].map((name) => [name, modelNames[name]]),
|
|
69
|
+
)}
|
|
70
|
+
onValueChange={(name) => {
|
|
71
|
+
if (name) {
|
|
72
|
+
onChange(replaceStage(value, stage, defaultModel(name as ModelName)))
|
|
73
|
+
}
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<SelectTrigger
|
|
77
|
+
aria-label={t('settings.pipeline.modelLabel', { stage: title })}
|
|
78
|
+
className='h-8 min-w-0 flex-1 text-[11px]'
|
|
79
|
+
>
|
|
80
|
+
<SelectValue />
|
|
81
|
+
</SelectTrigger>
|
|
82
|
+
<SelectContent>
|
|
83
|
+
{modelOptions[stage].map((name) => (
|
|
84
|
+
<SelectItem key={name} value={name}>
|
|
85
|
+
{modelNames[name]}
|
|
86
|
+
</SelectItem>
|
|
87
|
+
))}
|
|
88
|
+
</SelectContent>
|
|
89
|
+
</Select>
|
|
90
|
+
</div>
|
|
91
|
+
<ModelOptions
|
|
92
|
+
model={model}
|
|
93
|
+
onChange={(next) => onChange(replaceStage(value, stage, next))}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</PreferenceRow>
|
|
97
|
+
)
|
|
98
|
+
})}
|
|
99
|
+
</PreferenceSection>
|
|
100
|
+
</PreferencePage>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ModelOptions({
|
|
105
|
+
model,
|
|
106
|
+
onChange,
|
|
107
|
+
}: {
|
|
108
|
+
model: PipelineModel
|
|
109
|
+
onChange: (model: PipelineModel) => void
|
|
110
|
+
}) {
|
|
111
|
+
const { t } = useTranslation()
|
|
112
|
+
switch (model.model) {
|
|
113
|
+
case 'koharu-layout-rfdetr-seg-2xl':
|
|
114
|
+
return (
|
|
115
|
+
<div className='grid grid-cols-3 gap-2'>
|
|
116
|
+
<NumberField
|
|
117
|
+
label={t('settings.pipeline.options.textThreshold')}
|
|
118
|
+
value={model.text_threshold ?? null}
|
|
119
|
+
min={0}
|
|
120
|
+
max={1}
|
|
121
|
+
step={0.05}
|
|
122
|
+
onChange={(text_threshold) => onChange({ ...model, text_threshold })}
|
|
123
|
+
/>
|
|
124
|
+
<NumberField
|
|
125
|
+
label={t('settings.pipeline.options.bubbleThreshold')}
|
|
126
|
+
value={model.bubble_threshold ?? null}
|
|
127
|
+
min={0}
|
|
128
|
+
max={1}
|
|
129
|
+
step={0.05}
|
|
130
|
+
onChange={(bubble_threshold) => onChange({ ...model, bubble_threshold })}
|
|
131
|
+
/>
|
|
132
|
+
<NumberField
|
|
133
|
+
label={t('settings.pipeline.options.panelThreshold')}
|
|
134
|
+
value={model.panel_threshold ?? null}
|
|
135
|
+
min={0}
|
|
136
|
+
max={1}
|
|
137
|
+
step={0.05}
|
|
138
|
+
onChange={(panel_threshold) => onChange({ ...model, panel_threshold })}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
case 'flux2-klein':
|
|
143
|
+
return (
|
|
144
|
+
<TextField
|
|
145
|
+
label={t('settings.pipeline.options.prompt')}
|
|
146
|
+
value={model.prompt ?? 'Remove the text and reconstruct the background.'}
|
|
147
|
+
onChange={(prompt) => onChange({ ...model, prompt })}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
case 'rorem-mixed':
|
|
151
|
+
return (
|
|
152
|
+
<div className='grid grid-cols-2 gap-2'>
|
|
153
|
+
<TextField
|
|
154
|
+
label={t('settings.pipeline.options.prompt')}
|
|
155
|
+
value={model.prompt ?? ''}
|
|
156
|
+
onChange={(prompt) => onChange({ ...model, prompt })}
|
|
157
|
+
/>
|
|
158
|
+
<TextField
|
|
159
|
+
label={t('settings.pipeline.options.negativePrompt')}
|
|
160
|
+
value={model.negative_prompt ?? ''}
|
|
161
|
+
onChange={(negative_prompt) => onChange({ ...model, negative_prompt })}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
case 'paddleocr-vl-1.6':
|
|
166
|
+
case 'manga-ocr':
|
|
167
|
+
case 'baberu-ocr':
|
|
168
|
+
case 'hayai-ocr':
|
|
169
|
+
case 'lama':
|
|
170
|
+
case 'aot-inpainting':
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
|
|
6
|
+
import { Input } from '@aphrody/m3-primitives'
|
|
7
|
+
|
|
8
|
+
export function PreferencePage({
|
|
9
|
+
title,
|
|
10
|
+
description,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
children: React.ReactNode
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<div className='max-w-3xl'>
|
|
19
|
+
<header className='mb-8'>
|
|
20
|
+
<h2 className='text-[20px] font-semibold tracking-[-0.02em]'>{title}</h2>
|
|
21
|
+
<p className='mt-1 max-w-xl text-[12px] leading-5 text-muted-foreground'>{description}</p>
|
|
22
|
+
</header>
|
|
23
|
+
<div className='grid gap-8'>{children}</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PreferenceSection({
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
title: string
|
|
34
|
+
description?: string
|
|
35
|
+
children: React.ReactNode
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<section>
|
|
39
|
+
<div className='mb-3'>
|
|
40
|
+
<h3 className='text-[12px] font-semibold'>{title}</h3>
|
|
41
|
+
{description && (
|
|
42
|
+
<p className='mt-0.5 text-[11px] leading-4 text-muted-foreground'>{description}</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
<div className='divide-y divide-border rounded-xl border border-border/80 bg-[var(--surface-panel)] px-5'>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function PreferenceRow({
|
|
53
|
+
title,
|
|
54
|
+
description,
|
|
55
|
+
children,
|
|
56
|
+
align = 'center',
|
|
57
|
+
}: {
|
|
58
|
+
title: string
|
|
59
|
+
description?: string
|
|
60
|
+
children: React.ReactNode
|
|
61
|
+
align?: 'center' | 'start'
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={`grid grid-cols-[minmax(150px,0.9fr)_minmax(220px,1.1fr)] gap-6 py-3.5 ${align === 'start' ? 'items-start' : 'items-center'}`}
|
|
66
|
+
>
|
|
67
|
+
<div>
|
|
68
|
+
<p className='text-[12px] font-medium'>{title}</p>
|
|
69
|
+
{description && (
|
|
70
|
+
<p className='mt-0.5 text-[10px] leading-4 text-muted-foreground'>{description}</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<div className='min-w-0'>{children}</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function TextField({
|
|
79
|
+
label,
|
|
80
|
+
value,
|
|
81
|
+
type = 'text',
|
|
82
|
+
onChange,
|
|
83
|
+
}: {
|
|
84
|
+
label: string
|
|
85
|
+
value: string
|
|
86
|
+
type?: 'text' | 'url'
|
|
87
|
+
onChange: (value: string) => void
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<label className='grid gap-1 text-[10px] text-muted-foreground'>
|
|
91
|
+
{label}
|
|
92
|
+
<Input
|
|
93
|
+
type={type}
|
|
94
|
+
value={value}
|
|
95
|
+
className='h-8 text-[12px] text-foreground'
|
|
96
|
+
onChange={(event) => onChange(event.currentTarget.value)}
|
|
97
|
+
/>
|
|
98
|
+
</label>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `min` and `max` on a number input are validation hints the browser never
|
|
104
|
+
* enforces on typed text, so bounds are applied here before the value reaches
|
|
105
|
+
* the stored configuration.
|
|
106
|
+
*/
|
|
107
|
+
function parseBounded(raw: string, min?: number, max?: number) {
|
|
108
|
+
if (raw === '') return null
|
|
109
|
+
const value = Number(raw)
|
|
110
|
+
if (!Number.isFinite(value)) return null
|
|
111
|
+
if (min !== undefined && value < min) return min
|
|
112
|
+
if (max !== undefined && value > max) return max
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function NumberField({
|
|
117
|
+
label,
|
|
118
|
+
value,
|
|
119
|
+
min,
|
|
120
|
+
max,
|
|
121
|
+
step,
|
|
122
|
+
onChange,
|
|
123
|
+
}: {
|
|
124
|
+
label: string
|
|
125
|
+
value: number | null
|
|
126
|
+
min?: number
|
|
127
|
+
max?: number
|
|
128
|
+
step?: number
|
|
129
|
+
onChange: (value: number | null) => void
|
|
130
|
+
}) {
|
|
131
|
+
const { t } = useTranslation()
|
|
132
|
+
const id = useId()
|
|
133
|
+
return (
|
|
134
|
+
<label className='grid gap-1 text-[10px] text-muted-foreground'>
|
|
135
|
+
{label}
|
|
136
|
+
<Input
|
|
137
|
+
id={id}
|
|
138
|
+
type='number'
|
|
139
|
+
value={value ?? ''}
|
|
140
|
+
min={min}
|
|
141
|
+
max={max}
|
|
142
|
+
step={step}
|
|
143
|
+
placeholder={t('model.default')}
|
|
144
|
+
className='h-8 text-[12px] text-foreground'
|
|
145
|
+
onChange={(event) => onChange(parseBounded(event.currentTarget.value, min, max))}
|
|
146
|
+
/>
|
|
147
|
+
</label>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Eraser } from 'lucide-react'
|
|
4
|
+
import { useEffect, useId, useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PreferencePage,
|
|
9
|
+
PreferenceRow,
|
|
10
|
+
PreferenceSection,
|
|
11
|
+
TextField,
|
|
12
|
+
} from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
|
|
13
|
+
import type {
|
|
14
|
+
CredentialInput,
|
|
15
|
+
ProviderConfig,
|
|
16
|
+
ProviderPreference,
|
|
17
|
+
ProviderPreferences as ProviderSettings,
|
|
18
|
+
} from '@aphrody/koharu-bridge/protocol'
|
|
19
|
+
import { Button } from '../../internal/components/button'
|
|
20
|
+
import { Input } from '../../internal/components/input'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
type ConfigWithSetting<T, Key extends PropertyKey> = T extends { settings: infer Settings }
|
|
24
|
+
? Key extends keyof Settings
|
|
25
|
+
? [Settings[Key]] extends [never]
|
|
26
|
+
? never
|
|
27
|
+
: T
|
|
28
|
+
: never
|
|
29
|
+
: never
|
|
30
|
+
|
|
31
|
+
type BaseUrlConfig = ConfigWithSetting<ProviderConfig, 'base_url'>
|
|
32
|
+
|
|
33
|
+
export function ProviderPreferences({
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
}: {
|
|
37
|
+
value: ProviderSettings
|
|
38
|
+
onChange: (value: ProviderSettings) => void
|
|
39
|
+
}) {
|
|
40
|
+
const { t } = useTranslation()
|
|
41
|
+
return (
|
|
42
|
+
<PreferencePage
|
|
43
|
+
title={t('settings.providers.title')}
|
|
44
|
+
description={t('settings.providers.description')}
|
|
45
|
+
>
|
|
46
|
+
<PreferenceSection
|
|
47
|
+
title={t('settings.providers.connections')}
|
|
48
|
+
description={t('settings.providers.connectionsDescription')}
|
|
49
|
+
>
|
|
50
|
+
{value.entries.filter(isConfigurable).map((entry) => (
|
|
51
|
+
<PreferenceRow
|
|
52
|
+
key={entry.config.provider}
|
|
53
|
+
title={entry.name}
|
|
54
|
+
description={t(`providerDescriptions.${entry.config.provider}`)}
|
|
55
|
+
align='start'
|
|
56
|
+
>
|
|
57
|
+
<div className='grid gap-2'>
|
|
58
|
+
{entry.credential && (
|
|
59
|
+
<CredentialField
|
|
60
|
+
label={entry.name}
|
|
61
|
+
value={entry.credential}
|
|
62
|
+
onChange={(credential) => onChange(replaceEntry(value, { ...entry, credential }))}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
{hasBaseUrl(entry.config) && (
|
|
66
|
+
<TextField
|
|
67
|
+
label={t('model.baseUrl')}
|
|
68
|
+
type='url'
|
|
69
|
+
value={entry.config.settings.base_url ?? ''}
|
|
70
|
+
onChange={(base_url) =>
|
|
71
|
+
onChange(
|
|
72
|
+
replaceEntry(value, {
|
|
73
|
+
...entry,
|
|
74
|
+
config: withBaseUrl(entry.config, base_url),
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</PreferenceRow>
|
|
82
|
+
))}
|
|
83
|
+
</PreferenceSection>
|
|
84
|
+
</PreferencePage>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function CredentialField({
|
|
89
|
+
label,
|
|
90
|
+
value,
|
|
91
|
+
onChange,
|
|
92
|
+
}: {
|
|
93
|
+
label: string
|
|
94
|
+
value: CredentialInput
|
|
95
|
+
onChange: (value: CredentialInput) => void
|
|
96
|
+
}) {
|
|
97
|
+
const { t } = useTranslation()
|
|
98
|
+
const inputId = useId()
|
|
99
|
+
const [draftValue, setDraftValue] = useState(value.value ?? '')
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (value.value !== null) setDraftValue(value.value)
|
|
102
|
+
else if (!value.configured || value.clear) setDraftValue('')
|
|
103
|
+
}, [value.clear, value.configured, value.value])
|
|
104
|
+
const configured = !value.clear && (value.configured || Boolean(draftValue))
|
|
105
|
+
return (
|
|
106
|
+
<div className='grid gap-1'>
|
|
107
|
+
<label htmlFor={inputId} className='text-[10px] text-muted-foreground'>
|
|
108
|
+
{t('settings.providers.credential')}
|
|
109
|
+
</label>
|
|
110
|
+
<div className='flex gap-2'>
|
|
111
|
+
<Input
|
|
112
|
+
id={inputId}
|
|
113
|
+
aria-label={t('settings.providers.credentialLabel', { provider: label })}
|
|
114
|
+
type='text'
|
|
115
|
+
autoComplete='off'
|
|
116
|
+
autoCapitalize='none'
|
|
117
|
+
spellCheck={false}
|
|
118
|
+
value={draftValue}
|
|
119
|
+
placeholder={
|
|
120
|
+
configured ? t('settings.providers.configured') : t('settings.providers.notConfigured')
|
|
121
|
+
}
|
|
122
|
+
className='h-8 min-w-0 flex-1 text-[12px] [-webkit-text-security:disc] [&::placeholder]:[-webkit-text-security:none]'
|
|
123
|
+
onChange={(event) => {
|
|
124
|
+
const draft = event.currentTarget.value
|
|
125
|
+
setDraftValue(draft)
|
|
126
|
+
onChange({ ...value, value: draft || null, clear: false })
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
{configured && (
|
|
130
|
+
<Button
|
|
131
|
+
type='button'
|
|
132
|
+
variant='outline'
|
|
133
|
+
size='icon'
|
|
134
|
+
aria-label={t('settings.providers.clearCredential', { provider: label })}
|
|
135
|
+
onClick={() => {
|
|
136
|
+
setDraftValue('')
|
|
137
|
+
onChange({ configured: false, value: null, clear: true })
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<Eraser />
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isConfigurable(entry: ProviderPreference): boolean {
|
|
149
|
+
return entry.credential !== null || hasBaseUrl(entry.config)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function hasBaseUrl(config: ProviderConfig): config is BaseUrlConfig {
|
|
153
|
+
return 'base_url' in config.settings
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function withBaseUrl(config: ProviderConfig, base_url: string): ProviderConfig {
|
|
157
|
+
if (!hasBaseUrl(config)) return config
|
|
158
|
+
return {
|
|
159
|
+
...config,
|
|
160
|
+
settings: { ...config.settings, base_url: base_url || null },
|
|
161
|
+
} as ProviderConfig
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function replaceEntry(
|
|
165
|
+
preferences: ProviderSettings,
|
|
166
|
+
replacement: ProviderPreference,
|
|
167
|
+
): ProviderSettings {
|
|
168
|
+
return {
|
|
169
|
+
entries: preferences.entries.map((entry) =>
|
|
170
|
+
entry.config.provider === replacement.config.provider ? replacement : entry,
|
|
171
|
+
),
|
|
172
|
+
}
|
|
173
|
+
}
|