@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,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
+ }