@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,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
activateCanvas,
|
|
7
|
+
cancelCanvasPrefetch,
|
|
8
|
+
createCanvas,
|
|
9
|
+
fetchCanvasManifest,
|
|
10
|
+
fetchCanvasResource,
|
|
11
|
+
type Canvas,
|
|
12
|
+
} from '@aphrody/koharu-bridge/canvas'
|
|
13
|
+
|
|
14
|
+
export type CanvasStatus = 'loading' | 'switching' | 'ready' | 'recovering' | 'error'
|
|
15
|
+
|
|
16
|
+
interface CanvasState {
|
|
17
|
+
canvas: Canvas | null
|
|
18
|
+
error: Error | null
|
|
19
|
+
generation: number | null
|
|
20
|
+
hasFrame: boolean
|
|
21
|
+
retry: () => void
|
|
22
|
+
status: CanvasStatus
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useCanvas(
|
|
26
|
+
element: HTMLCanvasElement | null,
|
|
27
|
+
revision: number | null,
|
|
28
|
+
generation: number,
|
|
29
|
+
): CanvasState {
|
|
30
|
+
const [canvasAttempt, setCanvasAttempt] = useState(0)
|
|
31
|
+
const [loadAttempt, setLoadAttempt] = useState(0)
|
|
32
|
+
const [canvas, setCanvas] = useState<Canvas | null>(null)
|
|
33
|
+
const [status, setStatus] = useState<CanvasStatus>('loading')
|
|
34
|
+
const [error, setError] = useState<Error | null>(null)
|
|
35
|
+
const [activeGeneration, setActiveGeneration] = useState<number | null>(null)
|
|
36
|
+
const [hasFrame, setHasFrame] = useState(false)
|
|
37
|
+
const hasFrameRef = useRef(false)
|
|
38
|
+
const generationRef = useRef(generation)
|
|
39
|
+
const revisionRef = useRef(revision)
|
|
40
|
+
const activeFrame = useRef<{
|
|
41
|
+
canvas: Canvas
|
|
42
|
+
generation: number
|
|
43
|
+
} | null>(null)
|
|
44
|
+
const request = useRef<object | null>(null)
|
|
45
|
+
generationRef.current = generation
|
|
46
|
+
revisionRef.current = revision
|
|
47
|
+
|
|
48
|
+
const updateHasFrame = useCallback((value: boolean) => {
|
|
49
|
+
hasFrameRef.current = value
|
|
50
|
+
setHasFrame(value)
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const retry = useCallback(() => {
|
|
54
|
+
if (canvas) setLoadAttempt((value) => value + 1)
|
|
55
|
+
else setCanvasAttempt((value) => value + 1)
|
|
56
|
+
}, [canvas])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!element) return
|
|
60
|
+
let active = true
|
|
61
|
+
let owned: Canvas | null = null
|
|
62
|
+
setCanvas(null)
|
|
63
|
+
setError(null)
|
|
64
|
+
setActiveGeneration(null)
|
|
65
|
+
updateHasFrame(false)
|
|
66
|
+
setStatus(canvasAttempt === 0 ? 'loading' : 'recovering')
|
|
67
|
+
|
|
68
|
+
void createCanvas(element, () => {
|
|
69
|
+
if (!active) return
|
|
70
|
+
setStatus('recovering')
|
|
71
|
+
setCanvasAttempt((value) => value + 1)
|
|
72
|
+
})
|
|
73
|
+
.then((created) => {
|
|
74
|
+
if (!active) {
|
|
75
|
+
created.dispose()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
owned = created
|
|
79
|
+
setCanvas(created)
|
|
80
|
+
})
|
|
81
|
+
.catch((reason: unknown) => {
|
|
82
|
+
if (!active) return
|
|
83
|
+
setError(toError(reason))
|
|
84
|
+
setStatus('error')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
active = false
|
|
89
|
+
request.current = null
|
|
90
|
+
activeFrame.current = null
|
|
91
|
+
owned?.dispose()
|
|
92
|
+
}
|
|
93
|
+
}, [canvasAttempt, element, updateHasFrame])
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!canvas) return
|
|
97
|
+
if (revision === null) {
|
|
98
|
+
cancelCanvasPrefetch()
|
|
99
|
+
request.current = null
|
|
100
|
+
if (
|
|
101
|
+
!hasFrameRef.current &&
|
|
102
|
+
activeFrame.current?.canvas === canvas &&
|
|
103
|
+
activeFrame.current.generation === generation
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
try {
|
|
107
|
+
canvas.clear()
|
|
108
|
+
activeFrame.current = { canvas, generation }
|
|
109
|
+
setActiveGeneration(generation)
|
|
110
|
+
updateHasFrame(false)
|
|
111
|
+
setError(null)
|
|
112
|
+
setStatus('ready')
|
|
113
|
+
} catch (reason) {
|
|
114
|
+
setError(toError(reason))
|
|
115
|
+
setStatus('error')
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
generation === 0 ||
|
|
121
|
+
(activeFrame.current?.canvas === canvas && activeFrame.current.generation === generation)
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
const requested = generation
|
|
126
|
+
cancelCanvasPrefetch()
|
|
127
|
+
const currentRequest = {}
|
|
128
|
+
request.current = currentRequest
|
|
129
|
+
setError(null)
|
|
130
|
+
setStatus((current) =>
|
|
131
|
+
hasFrameRef.current ? 'switching' : current === 'recovering' ? 'recovering' : 'loading',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const current = () =>
|
|
135
|
+
request.current === currentRequest &&
|
|
136
|
+
generationRef.current === requested &&
|
|
137
|
+
revisionRef.current !== null
|
|
138
|
+
|
|
139
|
+
void prepareFrame(canvas, requested, current)
|
|
140
|
+
.then((activated) => {
|
|
141
|
+
if (!current() || !activated) return
|
|
142
|
+
activeFrame.current = { canvas, generation: requested }
|
|
143
|
+
setActiveGeneration(requested)
|
|
144
|
+
request.current = null
|
|
145
|
+
updateHasFrame(true)
|
|
146
|
+
setError(null)
|
|
147
|
+
setStatus('ready')
|
|
148
|
+
})
|
|
149
|
+
.catch((reason: unknown) => {
|
|
150
|
+
if (!current()) return
|
|
151
|
+
request.current = null
|
|
152
|
+
setError(toError(reason))
|
|
153
|
+
setStatus('error')
|
|
154
|
+
})
|
|
155
|
+
}, [canvas, generation, loadAttempt, revision, updateHasFrame])
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!canvas) return
|
|
159
|
+
return activateCanvas(canvas)
|
|
160
|
+
}, [canvas])
|
|
161
|
+
|
|
162
|
+
return { canvas, error, generation: activeGeneration, hasFrame, retry, status }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function prepareFrame(
|
|
166
|
+
canvas: Canvas,
|
|
167
|
+
generation: number,
|
|
168
|
+
current: () => boolean,
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
const manifest = await fetchCanvasManifest(generation)
|
|
171
|
+
if (!current()) return false
|
|
172
|
+
if (canvas.hasActiveManifest(manifest)) return true
|
|
173
|
+
const staged = canvas.stageManifest(manifest)
|
|
174
|
+
for (let offset = 0; offset < staged.missing.length; offset += 4) {
|
|
175
|
+
const resources = staged.missing.slice(offset, offset + 4)
|
|
176
|
+
const packets = await Promise.all(
|
|
177
|
+
resources.map(
|
|
178
|
+
async (resource) => [resource, await fetchCanvasResource(generation, resource)] as const,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
if (!current()) return false
|
|
182
|
+
await Promise.all(packets.map(([resource, packet]) => canvas.installResource(resource, packet)))
|
|
183
|
+
}
|
|
184
|
+
if (!current()) return false
|
|
185
|
+
const activated = canvas.activateFrame(staged.token)
|
|
186
|
+
if (!current()) return false
|
|
187
|
+
if (!activated) throw new Error('The prepared canvas frame was superseded before activation.')
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toError(value: unknown): Error {
|
|
192
|
+
if (value instanceof Error) return value
|
|
193
|
+
return new Error(typeof value === 'string' ? value : 'Could not initialize the WebGPU canvas.')
|
|
194
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
NumberField,
|
|
7
|
+
PreferenceRow,
|
|
8
|
+
PreferenceSection,
|
|
9
|
+
} from '@aphrody/koharu-ui/components/preferences/PreferenceFields'
|
|
10
|
+
import type { GenerationConfig } from '@aphrody/koharu-bridge/protocol'
|
|
11
|
+
import { Switch } from '@aphrody/m3-primitives'
|
|
12
|
+
|
|
13
|
+
export function GenerationPreferences({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
}: {
|
|
17
|
+
value: GenerationConfig
|
|
18
|
+
onChange: (value: GenerationConfig) => void
|
|
19
|
+
}) {
|
|
20
|
+
const { t } = useTranslation()
|
|
21
|
+
const update = (changes: Partial<GenerationConfig>) => onChange({ ...value, ...changes })
|
|
22
|
+
return (
|
|
23
|
+
<PreferenceSection
|
|
24
|
+
title={t('settings.generation.title')}
|
|
25
|
+
description={t('settings.generation.description')}
|
|
26
|
+
>
|
|
27
|
+
<PreferenceRow
|
|
28
|
+
title={t('settings.generation.sampling')}
|
|
29
|
+
description={t('settings.generation.samplingDescription')}
|
|
30
|
+
align='start'
|
|
31
|
+
>
|
|
32
|
+
<div className='grid grid-cols-2 gap-2'>
|
|
33
|
+
<NumberField
|
|
34
|
+
label={t('settings.generation.temperature')}
|
|
35
|
+
value={value.temperature ?? null}
|
|
36
|
+
min={0}
|
|
37
|
+
max={2}
|
|
38
|
+
step={0.1}
|
|
39
|
+
onChange={(temperature) => update({ temperature })}
|
|
40
|
+
/>
|
|
41
|
+
<NumberField
|
|
42
|
+
label={t('settings.generation.topK')}
|
|
43
|
+
value={value.top_k ?? null}
|
|
44
|
+
min={1}
|
|
45
|
+
step={1}
|
|
46
|
+
onChange={(top_k) => update({ top_k })}
|
|
47
|
+
/>
|
|
48
|
+
<NumberField
|
|
49
|
+
label={t('settings.generation.topP')}
|
|
50
|
+
value={value.top_p ?? null}
|
|
51
|
+
min={0}
|
|
52
|
+
max={1}
|
|
53
|
+
step={0.05}
|
|
54
|
+
onChange={(top_p) => update({ top_p })}
|
|
55
|
+
/>
|
|
56
|
+
<NumberField
|
|
57
|
+
label={t('settings.generation.minP')}
|
|
58
|
+
value={value.min_p ?? null}
|
|
59
|
+
min={0}
|
|
60
|
+
max={1}
|
|
61
|
+
step={0.05}
|
|
62
|
+
onChange={(min_p) => update({ min_p })}
|
|
63
|
+
/>
|
|
64
|
+
<NumberField
|
|
65
|
+
label={t('settings.generation.maxTokens')}
|
|
66
|
+
value={value.max_tokens ?? null}
|
|
67
|
+
min={1}
|
|
68
|
+
step={1}
|
|
69
|
+
onChange={(max_tokens) => update({ max_tokens })}
|
|
70
|
+
/>
|
|
71
|
+
<NumberField
|
|
72
|
+
label={t('settings.generation.repeatPenalty')}
|
|
73
|
+
value={value.repeat_penalty ?? null}
|
|
74
|
+
min={0}
|
|
75
|
+
step={0.05}
|
|
76
|
+
onChange={(repeat_penalty) => update({ repeat_penalty })}
|
|
77
|
+
/>
|
|
78
|
+
<NumberField
|
|
79
|
+
label={t('settings.generation.frequencyPenalty')}
|
|
80
|
+
value={value.frequency_penalty ?? null}
|
|
81
|
+
step={0.1}
|
|
82
|
+
onChange={(frequency_penalty) => update({ frequency_penalty })}
|
|
83
|
+
/>
|
|
84
|
+
<NumberField
|
|
85
|
+
label={t('settings.generation.presencePenalty')}
|
|
86
|
+
value={value.presence_penalty ?? null}
|
|
87
|
+
step={0.1}
|
|
88
|
+
onChange={(presence_penalty) => update({ presence_penalty })}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
</PreferenceRow>
|
|
92
|
+
<PreferenceRow
|
|
93
|
+
title={t('settings.generation.reasoning')}
|
|
94
|
+
description={t('settings.generation.reasoningDescription')}
|
|
95
|
+
>
|
|
96
|
+
<div className='flex h-8 items-center justify-end'>
|
|
97
|
+
<Switch
|
|
98
|
+
aria-label={t('settings.generation.enableReasoning')}
|
|
99
|
+
checked={value.reasoning ?? false}
|
|
100
|
+
onCheckedChange={(reasoning) => update({ reasoning })}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</PreferenceRow>
|
|
104
|
+
<PreferenceRow
|
|
105
|
+
title={t('settings.generation.vision')}
|
|
106
|
+
description={t('settings.generation.visionDescription')}
|
|
107
|
+
>
|
|
108
|
+
<div className='flex h-8 items-center justify-end'>
|
|
109
|
+
<Switch
|
|
110
|
+
aria-label={t('settings.generation.vision')}
|
|
111
|
+
checked={value.vision ?? false}
|
|
112
|
+
onCheckedChange={(vision) => update({ vision })}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</PreferenceRow>
|
|
116
|
+
</PreferenceSection>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Koharu settings use the same JSONL methods as the headless CLI and Bun SDK.
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { Button, Card } from "@aphrody/m3-primitives";
|
|
5
|
+
import { createSettingsClient, type KoharuPipeline, type KoharuPipelineCatalog, type KoharuSettings as Preferences, type KoharuTranslationModel } from "@aphrody/koharu-sdk/settings";
|
|
6
|
+
|
|
7
|
+
type Section = "pipeline" | "translation" | "providers" | "typesetting";
|
|
8
|
+
|
|
9
|
+
const sections: Array<{ id: Section; label: string }> = [
|
|
10
|
+
{ id: "pipeline", label: "Pipeline" },
|
|
11
|
+
{ id: "translation", label: "Traduction" },
|
|
12
|
+
{ id: "providers", label: "Fournisseurs" },
|
|
13
|
+
{ id: "typesetting", label: "Typographie" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export interface KoharuSettingsHost {
|
|
17
|
+
isTauri(): boolean;
|
|
18
|
+
invoke<T>(command: string, args: Record<string, unknown>): Promise<T | null>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fieldClass = "min-h-12 w-full rounded-xl border border-outline bg-surface-container px-4 text-[18px] text-on-surface focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-primary";
|
|
22
|
+
const buttonClass = "min-h-12 px-5 text-[18px] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-primary";
|
|
23
|
+
|
|
24
|
+
function Field({ label, value, onChange, type = "text" }: {
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
onChange(value: string): void;
|
|
28
|
+
type?: "text" | "password" | "url";
|
|
29
|
+
}) {
|
|
30
|
+
return <label className="grid gap-2 text-[18px] text-on-surface">
|
|
31
|
+
<span>{label}</span>
|
|
32
|
+
<input className={fieldClass} type={type} value={value} onChange={(event) => onChange(event.currentTarget.value)} />
|
|
33
|
+
</label>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ModelField({ label, value, choices, onChange }: {
|
|
37
|
+
label: string;
|
|
38
|
+
value: string;
|
|
39
|
+
choices: string[];
|
|
40
|
+
onChange(value: string): void;
|
|
41
|
+
}) {
|
|
42
|
+
const options = choices.includes(value) ? choices : [value, ...choices];
|
|
43
|
+
return <label className="grid gap-2 text-[18px] text-on-surface">
|
|
44
|
+
<span>{label}</span>
|
|
45
|
+
<select className={fieldClass} value={value} onChange={(event) => onChange(event.currentTarget.value)}>
|
|
46
|
+
{options.map((option) => <option value={option} key={option}>{option}</option>)}
|
|
47
|
+
</select>
|
|
48
|
+
</label>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function KoharuSettings({ host }: { host: KoharuSettingsHost }) {
|
|
52
|
+
const settingsClient = useMemo(() => createSettingsClient(async (method, params) => {
|
|
53
|
+
if (!host.isTauri()) throw new Error("Les réglages Koharu nécessitent l’application Aphrody.");
|
|
54
|
+
const result = await host.invoke<unknown>("koharu_rpc", { method, params });
|
|
55
|
+
if (result === null) throw new Error("Le service natif Koharu est indisponible.");
|
|
56
|
+
return result;
|
|
57
|
+
}), [host]);
|
|
58
|
+
const [section, setSection] = useState<Section>("pipeline");
|
|
59
|
+
const [preferences, setPreferences] = useState<Preferences | null>(null);
|
|
60
|
+
const [catalog, setCatalog] = useState<KoharuPipelineCatalog | null>(null);
|
|
61
|
+
const [translationModels, setTranslationModels] = useState<KoharuTranslationModel[]>([]);
|
|
62
|
+
const [pipelineDraft, setPipelineDraft] = useState("");
|
|
63
|
+
const [typesettingDraft, setTypesettingDraft] = useState("");
|
|
64
|
+
const [providerUrls, setProviderUrls] = useState<Record<string, string>>({});
|
|
65
|
+
const [credentialDrafts, setCredentialDrafts] = useState<Record<string, string>>({});
|
|
66
|
+
const [busy, setBusy] = useState(false);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const load = async () => {
|
|
71
|
+
const [current, models, pipelineCatalog] = await Promise.all([
|
|
72
|
+
settingsClient.get(),
|
|
73
|
+
settingsClient.translationModels(),
|
|
74
|
+
settingsClient.pipelineModels(),
|
|
75
|
+
]);
|
|
76
|
+
const next = current;
|
|
77
|
+
setPreferences(next);
|
|
78
|
+
setTranslationModels(models);
|
|
79
|
+
setCatalog(pipelineCatalog);
|
|
80
|
+
setPipelineDraft(JSON.stringify(next.pipeline, null, 2));
|
|
81
|
+
setTypesettingDraft((next.typesetting.font_families ?? []).join("\n"));
|
|
82
|
+
setProviderUrls(Object.fromEntries(next.providers.map(({ config }) => [config.provider, "base_url" in config.settings ? config.settings.base_url ?? "" : ""])));
|
|
83
|
+
setCredentialDrafts({});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let active = true;
|
|
88
|
+
void load().catch((cause) => {
|
|
89
|
+
if (active) setError(cause instanceof Error ? cause.message : String(cause));
|
|
90
|
+
});
|
|
91
|
+
return () => { active = false; };
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const save = async (action: () => Promise<unknown>) => {
|
|
95
|
+
setBusy(true);
|
|
96
|
+
setError(null);
|
|
97
|
+
setNotice(null);
|
|
98
|
+
try {
|
|
99
|
+
await action();
|
|
100
|
+
await load();
|
|
101
|
+
setNotice("Paramètres Koharu enregistrés.");
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
104
|
+
} finally {
|
|
105
|
+
setBusy(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const updatePipeline = (next: KoharuPipeline) => {
|
|
110
|
+
setPreferences((current) => current ? { ...current, pipeline: next } : current);
|
|
111
|
+
setPipelineDraft(JSON.stringify(next, null, 2));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const updateModel = (stage: "detection" | "ocr" | "inpainting", model: string) => {
|
|
115
|
+
if (!pipeline) return;
|
|
116
|
+
updatePipeline({ ...pipeline, [stage]: { model } });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const pipeline = preferences?.pipeline;
|
|
120
|
+
const translation = pipeline?.translation;
|
|
121
|
+
const selectedTranslation = translation?.model;
|
|
122
|
+
const selectedModel = translationModels.find((model) => model.provider === selectedTranslation?.provider && model.model === selectedTranslation.model);
|
|
123
|
+
const generationNumbers = [
|
|
124
|
+
["temperature", "Température"],
|
|
125
|
+
["top_k", "Top K"],
|
|
126
|
+
["top_p", "Top P"],
|
|
127
|
+
["min_p", "Min P"],
|
|
128
|
+
["max_tokens", "Nombre maximal de jetons"],
|
|
129
|
+
["repeat_penalty", "Pénalité de répétition"],
|
|
130
|
+
["frequency_penalty", "Pénalité de fréquence"],
|
|
131
|
+
["presence_penalty", "Pénalité de présence"],
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
return <div className="max-w-3xl space-y-6 text-[18px] text-on-surface">
|
|
135
|
+
<header>
|
|
136
|
+
<h2 className="text-headline-small font-bold">Koharu</h2>
|
|
137
|
+
<p className="mt-2 text-[18px] text-on-surface-variant">Traitement manga, traduction, fournisseurs et typographie.</p>
|
|
138
|
+
</header>
|
|
139
|
+
<nav aria-label="Paramètres Koharu" className="flex flex-wrap gap-2">
|
|
140
|
+
{sections.map(({ id, label }) => <button key={id} type="button" aria-current={section === id ? "page" : undefined}
|
|
141
|
+
className="min-h-12 rounded-full px-5 text-[18px] text-on-surface focus-visible:outline-3 focus-visible:outline-primary aria-[current=page]:bg-secondary-container"
|
|
142
|
+
onClick={() => setSection(id)}>{label}</button>)}
|
|
143
|
+
</nav>
|
|
144
|
+
{error && <p role="alert" className="rounded-xl bg-error-container p-4 text-on-error-container">{error}</p>}
|
|
145
|
+
{notice && <p role="status" className="rounded-xl bg-primary-container p-4 text-on-primary-container">{notice}</p>}
|
|
146
|
+
{!preferences && !error && <p role="status">Chargement des paramètres Koharu…</p>}
|
|
147
|
+
{pipeline && section === "pipeline" && <Card className="space-y-5 p-6">
|
|
148
|
+
<h3 className="text-title-large">Modèles du pipeline</h3>
|
|
149
|
+
{(["detection", "ocr", "inpainting"] as const).map((stage) => <ModelField key={stage} label={stage === "detection" ? "Détection" : stage === "ocr" ? "OCR" : "Effacement"}
|
|
150
|
+
value={pipeline[stage].model} choices={catalog?.[stage] ?? []}
|
|
151
|
+
onChange={(model) => updateModel(stage, model)} />)}
|
|
152
|
+
<details>
|
|
153
|
+
<summary className="cursor-pointer text-[18px]">Tous les paramètres du pipeline</summary>
|
|
154
|
+
<label className="mt-3 grid gap-2">Configuration JSON
|
|
155
|
+
<textarea className={`${fieldClass} min-h-80 font-mono`} spellCheck={false} value={pipelineDraft}
|
|
156
|
+
onChange={(event) => setPipelineDraft(event.currentTarget.value)} />
|
|
157
|
+
</label>
|
|
158
|
+
</details>
|
|
159
|
+
<Button className={buttonClass} disabled={busy} onClick={() => void save(() => settingsClient.setPipeline(JSON.parse(pipelineDraft) as KoharuPipeline))}>Enregistrer le pipeline</Button>
|
|
160
|
+
</Card>}
|
|
161
|
+
{pipeline && translation && section === "translation" && <Card className="space-y-5 p-6">
|
|
162
|
+
<h3 className="text-title-large">Traduction</h3>
|
|
163
|
+
<ModelField label="Modèle" value={selectedModel ? `${selectedModel.provider}:${selectedModel.model ?? ""}` : `${selectedTranslation?.provider}:${selectedTranslation?.model ?? ""}`}
|
|
164
|
+
choices={translationModels.map((model) => `${model.provider}:${model.model ?? ""}`)}
|
|
165
|
+
onChange={(key) => {
|
|
166
|
+
const model = translationModels.find((candidate) => `${candidate.provider}:${candidate.model ?? ""}` === key);
|
|
167
|
+
if (model) updatePipeline({ ...pipeline, translation: { ...translation, model: { provider: model.provider, model: model.model, vision: model.vision, reasoning: model.reasoning } } });
|
|
168
|
+
}} />
|
|
169
|
+
{selectedModel && selectedModel.quantizations.length > 0 && <ModelField label="Quantification"
|
|
170
|
+
value={typeof selectedTranslation?.quantization === "string" ? selectedTranslation.quantization : selectedModel.quantizations[0]!.id}
|
|
171
|
+
choices={selectedModel.quantizations.map((choice) => choice.id)}
|
|
172
|
+
onChange={(quantization) => updatePipeline({ ...pipeline, translation: { ...translation, model: { ...translation.model, quantization } } })} />}
|
|
173
|
+
<Field label="Langue cible" value={translation.target_language} onChange={(target_language) => updatePipeline({ ...pipeline, translation: { ...translation, target_language } })} />
|
|
174
|
+
<label className="grid gap-2">Instructions
|
|
175
|
+
<textarea className={`${fieldClass} min-h-36`} value={translation.instructions ?? ""}
|
|
176
|
+
onChange={(event) => updatePipeline({ ...pipeline, translation: { ...translation, instructions: event.currentTarget.value || null } })} />
|
|
177
|
+
</label>
|
|
178
|
+
<details>
|
|
179
|
+
<summary className="cursor-pointer text-[18px]">Paramètres de génération</summary>
|
|
180
|
+
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
181
|
+
{generationNumbers.map(([key, label]) => <label key={key} className="grid gap-2">{label}
|
|
182
|
+
<input className={fieldClass} type="number" step="any"
|
|
183
|
+
value={typeof translation.generation[key] === "number" ? translation.generation[key] as number : ""}
|
|
184
|
+
onChange={(event) => updatePipeline({ ...pipeline, translation: { ...translation,
|
|
185
|
+
generation: { ...translation.generation, [key]: event.currentTarget.value === "" ? null : Number(event.currentTarget.value) },
|
|
186
|
+
} })} />
|
|
187
|
+
</label>)}
|
|
188
|
+
{(["reasoning", "vision"] as const).map((key) => <label key={key} className="grid gap-2">{key === "reasoning" ? "Raisonnement" : "Vision"}
|
|
189
|
+
<select className={fieldClass} value={translation.generation[key] === null || translation.generation[key] === undefined ? "default" : String(translation.generation[key])}
|
|
190
|
+
onChange={(event) => updatePipeline({ ...pipeline, translation: { ...translation,
|
|
191
|
+
generation: { ...translation.generation, [key]: event.currentTarget.value === "default" ? null : event.currentTarget.value === "true" },
|
|
192
|
+
} })}>
|
|
193
|
+
<option value="default">Par défaut</option><option value="true">Activé</option><option value="false">Désactivé</option>
|
|
194
|
+
</select>
|
|
195
|
+
</label>)}
|
|
196
|
+
</div>
|
|
197
|
+
</details>
|
|
198
|
+
<Button className={buttonClass} disabled={busy} onClick={() => void save(() => settingsClient.setPipeline(JSON.parse(pipelineDraft) as KoharuPipeline))}>Enregistrer la traduction</Button>
|
|
199
|
+
</Card>}
|
|
200
|
+
{preferences && section === "providers" && <div className="space-y-4">
|
|
201
|
+
{preferences.providers.map(({ config, credential_configured }) => <Card key={config.provider} className="space-y-4 p-6">
|
|
202
|
+
<h3 className="text-title-large">{config.provider}</h3>
|
|
203
|
+
{"base_url" in config.settings && <Field label="URL du fournisseur" type="url" value={providerUrls[config.provider] ?? ""}
|
|
204
|
+
onChange={(value) => setProviderUrls((current) => ({ ...current, [config.provider]: value }))} />}
|
|
205
|
+
{config.provider !== "local" && <>
|
|
206
|
+
<p className="text-on-surface-variant">Identifiant configuré : {credential_configured ? "oui" : "non"}</p>
|
|
207
|
+
<Field label="Nouvel identifiant secret" type="password" value={credentialDrafts[config.provider] ?? ""}
|
|
208
|
+
onChange={(value) => setCredentialDrafts((current) => ({ ...current, [config.provider]: value }))} />
|
|
209
|
+
<div className="flex flex-wrap gap-3">
|
|
210
|
+
<Button className={buttonClass} disabled={busy || !credentialDrafts[config.provider]} onClick={() => void save(() => settingsClient.setCredential(config.provider, credentialDrafts[config.provider] ?? ""))}>Enregistrer le secret</Button>
|
|
211
|
+
{credential_configured && <Button className={buttonClass} disabled={busy} onClick={() => void save(() => settingsClient.setCredential(config.provider, null))}>Effacer le secret</Button>}
|
|
212
|
+
</div>
|
|
213
|
+
</>}
|
|
214
|
+
</Card>)}
|
|
215
|
+
<Button className={buttonClass} disabled={busy} onClick={() => void save(() => settingsClient.setProviders(
|
|
216
|
+
preferences.providers.map(({ config }) => ({ ...config, settings: "base_url" in config.settings ? { ...config.settings, base_url: providerUrls[config.provider] || null } : config.settings })),
|
|
217
|
+
))}>Enregistrer les fournisseurs</Button>
|
|
218
|
+
</div>}
|
|
219
|
+
{preferences && section === "typesetting" && <Card className="space-y-5 p-6">
|
|
220
|
+
<h3 className="text-title-large">Typographie</h3>
|
|
221
|
+
<label className="grid gap-2">Polices de remplacement, une par ligne
|
|
222
|
+
<textarea className={`${fieldClass} min-h-44`} value={typesettingDraft} onChange={(event) => setTypesettingDraft(event.currentTarget.value)} />
|
|
223
|
+
</label>
|
|
224
|
+
<Button className={buttonClass} disabled={busy} onClick={() => void save(() => settingsClient.setTypesetting(
|
|
225
|
+
{ ...preferences.typesetting, font_families: typesettingDraft.split("\n").map((name) => name.trim()).filter(Boolean) },
|
|
226
|
+
))}>Enregistrer la typographie</Button>
|
|
227
|
+
</Card>}
|
|
228
|
+
</div>;
|
|
229
|
+
}
|