fffonion commited on
Commit
7bea059
·
1 Parent(s): 67c1c1f

feat: add openai endpoint support

Browse files
ui/components/panels/LlmControls.tsx CHANGED
@@ -3,6 +3,7 @@
3
  import { useEffect, useState } from 'react'
4
  import { Select } from 'radix-ui'
5
  import { useTranslation } from 'react-i18next'
 
6
  import { useAppStore } from '@/lib/store'
7
  import { TooltipButton } from '@/components/ui/form-controls'
8
 
@@ -19,12 +20,21 @@ export function LlmControls() {
19
  llmToggleLoadUnload,
20
  llmGenerate,
21
  llmCheckReady,
 
 
 
 
 
 
 
 
22
  } = useAppStore()
23
  const [generating, setGenerating] = useState(false)
24
  const { t } = useTranslation()
25
 
26
  const activeLanguages =
27
  llmModels.find((model) => model.id === llmSelectedModel)?.languages ?? []
 
28
 
29
  useEffect(() => {
30
  llmList()
@@ -64,7 +74,11 @@ export function LlmControls() {
64
  value={model.id}
65
  className='rounded px-3 py-1.5 text-sm outline-none select-none hover:bg-black/5 data-[state=checked]:bg-black/5'
66
  >
67
- <Select.ItemText>{model.id}</Select.ItemText>
 
 
 
 
68
  </Select.Item>
69
  ))}
70
  </Select.Viewport>
@@ -103,14 +117,67 @@ export function LlmControls() {
103
  </div>
104
  ) : null}
105
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  <div className='flex gap-2'>
107
- <TooltipButton
108
- label={!llmReady ? t('llm.load') : t('llm.unload')}
109
- tooltip={!llmReady ? t('llm.loadTooltip') : t('llm.unloadTooltip')}
110
- widthClass='w-full'
111
- onClick={llmToggleLoadUnload}
112
- disabled={!llmSelectedModel || llmLoading}
113
- />
 
 
114
  <TooltipButton
115
  label={generating ? t('llm.generating') : t('llm.generate')}
116
  tooltip={t('llm.generateTooltip')}
@@ -119,6 +186,8 @@ export function LlmControls() {
119
  setGenerating(true)
120
  try {
121
  await llmGenerate(null)
 
 
122
  } finally {
123
  setGenerating(false)
124
  }
 
3
  import { useEffect, useState } from 'react'
4
  import { Select } from 'radix-ui'
5
  import { useTranslation } from 'react-i18next'
6
+ import { OPENAI_COMPATIBLE_MODEL_ID } from '@/lib/openai'
7
  import { useAppStore } from '@/lib/store'
8
  import { TooltipButton } from '@/components/ui/form-controls'
9
 
 
20
  llmToggleLoadUnload,
21
  llmGenerate,
22
  llmCheckReady,
23
+ llmOpenAIEndpoint,
24
+ llmOpenAIApiKey,
25
+ llmOpenAIPrompt,
26
+ llmOpenAIModel,
27
+ setLlmOpenAIEndpoint,
28
+ setLlmOpenAIApiKey,
29
+ setLlmOpenAIPrompt,
30
+ setLlmOpenAIModel,
31
  } = useAppStore()
32
  const [generating, setGenerating] = useState(false)
33
  const { t } = useTranslation()
34
 
35
  const activeLanguages =
36
  llmModels.find((model) => model.id === llmSelectedModel)?.languages ?? []
37
+ const isOpenAICompatible = llmSelectedModel === OPENAI_COMPATIBLE_MODEL_ID
38
 
39
  useEffect(() => {
40
  llmList()
 
74
  value={model.id}
75
  className='rounded px-3 py-1.5 text-sm outline-none select-none hover:bg-black/5 data-[state=checked]:bg-black/5'
76
  >
77
+ <Select.ItemText>
78
+ {model.id === OPENAI_COMPATIBLE_MODEL_ID
79
+ ? t('llm.openaiCompatible')
80
+ : model.id}
81
+ </Select.ItemText>
82
  </Select.Item>
83
  ))}
84
  </Select.Viewport>
 
117
  </div>
118
  ) : null}
119
  </div>
120
+ {isOpenAICompatible ? (
121
+ <div className='space-y-2 rounded border border-neutral-200 bg-white p-2'>
122
+ <div className='space-y-1'>
123
+ <div className='text-[11px] font-semibold tracking-wide text-neutral-500 uppercase'>
124
+ {t('llm.openaiEndpointLabel')}
125
+ </div>
126
+ <input
127
+ type='text'
128
+ value={llmOpenAIEndpoint}
129
+ placeholder={t('llm.openaiEndpointPlaceholder')}
130
+ onChange={(event) => setLlmOpenAIEndpoint(event.target.value)}
131
+ className='w-full rounded border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 outline-none focus:border-rose-400'
132
+ />
133
+ </div>
134
+ <div className='space-y-1'>
135
+ <div className='text-[11px] font-semibold tracking-wide text-neutral-500 uppercase'>
136
+ {t('llm.openaiApiKeyLabel')}
137
+ </div>
138
+ <input
139
+ type='password'
140
+ value={llmOpenAIApiKey}
141
+ placeholder={t('llm.openaiApiKeyPlaceholder')}
142
+ autoComplete='off'
143
+ onChange={(event) => setLlmOpenAIApiKey(event.target.value)}
144
+ className='w-full rounded border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 outline-none focus:border-rose-400'
145
+ />
146
+ </div>
147
+ <div className='space-y-1'>
148
+ <div className='text-[11px] font-semibold tracking-wide text-neutral-500 uppercase'>
149
+ {t('llm.openaiModelLabel')}
150
+ </div>
151
+ <input
152
+ value={llmOpenAIModel}
153
+ placeholder={t('llm.openaiModelPlaceholder')}
154
+ onChange={(event) => setLlmOpenAIModel(event.target.value)}
155
+ className='w-full rounded border border-neutral-200 bg-white px-2 py-1.5 text-sm text-neutral-800 outline-none focus:border-rose-400'
156
+ />
157
+ </div>
158
+ <div className='space-y-1'>
159
+ <div className='text-[11px] font-semibold tracking-wide text-neutral-500 uppercase'>
160
+ {t('llm.openaiPromptLabel')}
161
+ </div>
162
+ <textarea
163
+ value={llmOpenAIPrompt}
164
+ rows={3}
165
+ onChange={(event) => setLlmOpenAIPrompt(event.target.value)}
166
+ className='w-full rounded border border-neutral-200 bg-white px-2 py-2 text-sm text-neutral-800 outline-none focus:border-rose-400'
167
+ />
168
+ </div>
169
+ </div>
170
+ ) : null}
171
  <div className='flex gap-2'>
172
+ {!isOpenAICompatible && (
173
+ <TooltipButton
174
+ label={!llmReady ? t('llm.load') : t('llm.unload')}
175
+ tooltip={!llmReady ? t('llm.loadTooltip') : t('llm.unloadTooltip')}
176
+ widthClass='w-full'
177
+ onClick={llmToggleLoadUnload}
178
+ disabled={!llmSelectedModel || llmLoading || isOpenAICompatible}
179
+ />
180
+ )}
181
  <TooltipButton
182
  label={generating ? t('llm.generating') : t('llm.generate')}
183
  tooltip={t('llm.generateTooltip')}
 
186
  setGenerating(true)
187
  try {
188
  await llmGenerate(null)
189
+ } catch (error) {
190
+ console.error(error)
191
  } finally {
192
  setGenerating(false)
193
  }
ui/components/panels/TextBlocksPanel.tsx CHANGED
@@ -7,6 +7,7 @@ import { TextBlock } from '@/types'
7
  import { Languages } from 'lucide-react'
8
  import { TextareaField, TooltipButton } from '@/components/ui/form-controls'
9
  import { useTextBlocks } from '@/hooks/useTextBlocks'
 
10
  import { useAppStore } from '@/lib/store'
11
 
12
  export function TextBlocksPanel() {
@@ -18,8 +19,18 @@ export function TextBlocksPanel() {
18
  replaceBlock,
19
  } = useTextBlocks()
20
  const { t } = useTranslation()
21
- const { llmGenerate, llmReady } = useAppStore()
 
 
 
 
 
 
22
  const [generatingIndex, setGeneratingIndex] = useState<number | null>(null)
 
 
 
 
23
 
24
  if (!document) {
25
  return (
@@ -36,6 +47,8 @@ export function TextBlocksPanel() {
36
  setGeneratingIndex(blockIndex)
37
  try {
38
  await llmGenerate(undefined, undefined, blockIndex)
 
 
39
  } finally {
40
  setGeneratingIndex(null)
41
  }
@@ -75,7 +88,7 @@ export function TextBlocksPanel() {
75
  onChange={(updates) => void replaceBlock(index, updates)}
76
  onGenerate={() => void handleGenerate(index)}
77
  generating={generatingIndex === index}
78
- llmReady={llmReady}
79
  />
80
  ))}
81
  </Accordion.Root>
 
7
  import { Languages } from 'lucide-react'
8
  import { TextareaField, TooltipButton } from '@/components/ui/form-controls'
9
  import { useTextBlocks } from '@/hooks/useTextBlocks'
10
+ import { isOpenAIConfigured, OPENAI_COMPATIBLE_MODEL_ID } from '@/lib/openai'
11
  import { useAppStore } from '@/lib/store'
12
 
13
  export function TextBlocksPanel() {
 
19
  replaceBlock,
20
  } = useTextBlocks()
21
  const { t } = useTranslation()
22
+ const {
23
+ llmGenerate,
24
+ llmReady,
25
+ llmSelectedModel,
26
+ llmOpenAIEndpoint,
27
+ llmOpenAIApiKey,
28
+ } = useAppStore()
29
  const [generatingIndex, setGeneratingIndex] = useState<number | null>(null)
30
+ const llmAvailable =
31
+ llmSelectedModel === OPENAI_COMPATIBLE_MODEL_ID
32
+ ? isOpenAIConfigured(llmOpenAIEndpoint, llmOpenAIApiKey)
33
+ : llmReady
34
 
35
  if (!document) {
36
  return (
 
47
  setGeneratingIndex(blockIndex)
48
  try {
49
  await llmGenerate(undefined, undefined, blockIndex)
50
+ } catch (error) {
51
+ console.error(error)
52
  } finally {
53
  setGeneratingIndex(null)
54
  }
 
88
  onChange={(updates) => void replaceBlock(index, updates)}
89
  onGenerate={() => void handleGenerate(index)}
90
  generating={generatingIndex === index}
91
+ llmReady={llmAvailable}
92
  />
93
  ))}
94
  </Accordion.Root>
ui/lib/openai.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type LlmModelInfo = {
2
+ id: string
3
+ languages: string[]
4
+ }
5
+
6
+ export const OPENAI_COMPATIBLE_MODEL_ID = 'openai-compatible'
7
+ export const OPENAI_DEFAULT_MODEL = 'gpt-4o-mini'
8
+
9
+ export const OPENAI_COMPATIBLE_MODEL: LlmModelInfo = {
10
+ id: OPENAI_COMPATIBLE_MODEL_ID,
11
+ languages: [],
12
+ }
13
+
14
+ export const isOpenAIModel = (modelId?: string) =>
15
+ modelId === OPENAI_COMPATIBLE_MODEL_ID
16
+
17
+ export const isOpenAIConfigured = (endpoint: string, apiKey: string) =>
18
+ endpoint.trim().length > 0 || apiKey.trim().length > 0
19
+
20
+ const normalizeOpenAIEndpoint = (endpoint: string) => {
21
+ const baseEndpoint = endpoint.trim() || 'https://api.openai.com/v1/'
22
+
23
+ const parsed = new URL(baseEndpoint)
24
+ if (!parsed.pathname.endsWith('/chat/completions')) {
25
+ const normalizedPath = parsed.pathname.replace(/\/$/, '')
26
+ parsed.pathname = `${normalizedPath}/chat/completions`
27
+ }
28
+ return parsed.toString()
29
+ }
30
+
31
+ type OpenAIChoice = {
32
+ message?: { content?: string }
33
+ text?: string
34
+ }
35
+
36
+ const parseOpenAIResponse = (payload: { choices?: OpenAIChoice[] }) => {
37
+ const choice = payload?.choices?.[0]
38
+ const content = choice?.message?.content ?? choice?.text
39
+ if (!content) {
40
+ throw new Error('OpenAI compatible response missing content')
41
+ }
42
+ return content.trim()
43
+ }
44
+
45
+ export const callOpenAICompletion = async ({
46
+ endpoint,
47
+ apiKey,
48
+ prompt,
49
+ content,
50
+ model,
51
+ }: {
52
+ endpoint: string
53
+ apiKey: string
54
+ prompt: string
55
+ content: string
56
+ model?: string
57
+ }) => {
58
+ const resolvedEndpoint = endpoint.trim() || 'https://api.openai.com/v1/'
59
+ const url = normalizeOpenAIEndpoint(resolvedEndpoint)
60
+ if (resolvedEndpoint === 'https://api.openai.com/v1/' && !apiKey.trim()) {
61
+ throw new Error('OpenAI compatible API key is required')
62
+ }
63
+ const parsed = new URL(url)
64
+ const selectedModel =
65
+ model?.trim() || parsed.searchParams.get('model') || OPENAI_DEFAULT_MODEL
66
+ parsed.searchParams.delete('model')
67
+
68
+ const headers: Record<string, string> = {
69
+ 'Content-Type': 'application/json',
70
+ }
71
+ if (apiKey && apiKey.trim()) {
72
+ headers.Authorization = `Bearer ${apiKey}`
73
+ }
74
+
75
+ const response = await fetch(parsed.toString(), {
76
+ method: 'POST',
77
+ headers,
78
+ body: JSON.stringify({
79
+ model: selectedModel,
80
+ messages: [
81
+ { role: 'system', content: prompt },
82
+ { role: 'user', content },
83
+ ],
84
+ temperature: 0.2,
85
+ }),
86
+ })
87
+
88
+ if (!response.ok) {
89
+ const message = await response.text()
90
+ throw new Error(
91
+ `OpenAI compatible request failed (${response.status}): ${message}`,
92
+ )
93
+ }
94
+
95
+ const payload = (await response.json()) as { choices?: OpenAIChoice[] }
96
+ return parseOpenAIResponse(payload)
97
+ }
ui/lib/store.ts CHANGED
@@ -5,6 +5,15 @@ import { invoke } from '@tauri-apps/api/core'
5
  import { getCurrentWindow, ProgressBarStatus } from '@tauri-apps/api/window'
6
  import { Document, TextBlock, ToolMode } from '@/types'
7
  import { createOperationSlice, type OperationSlice } from '@/lib/operations'
 
 
 
 
 
 
 
 
 
8
 
9
  type ProcessImageOptions =
10
  | ((progress: number) => Promise<void>)
@@ -13,11 +22,6 @@ type ProcessImageOptions =
13
  skipOperationTracking?: boolean
14
  }
15
 
16
- export type LlmModelInfo = {
17
- id: string
18
- languages: string[]
19
- }
20
-
21
  const replaceDocument = (docs: Document[], index: number, doc: Document) =>
22
  docs.map((item, idx) => (idx === index ? doc : item))
23
 
@@ -89,6 +93,10 @@ type AppState = OperationSlice & {
89
  llmSelectedLanguage?: string
90
  llmReady: boolean
91
  llmLoading: boolean
 
 
 
 
92
  // ui + actions
93
  hydrateDocuments: (docs: Document[]) => void
94
  openDocuments: () => Promise<void>
@@ -104,7 +112,6 @@ type AppState = OperationSlice & {
104
  setSelectedBlockIndex: (index?: number) => void
105
  setAutoFitEnabled: (enabled: boolean) => void
106
  updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
107
- invokeWithStatus: (command: string, args?: any) => Promise<Document>
108
  setProgress: (progress?: number, status?: ProgressBarStatus) => Promise<void>
109
  clearProgress: () => Promise<void>
110
  // Processing actions
@@ -132,417 +139,534 @@ type AppState = OperationSlice & {
132
  index?: number,
133
  text_block_index?: number,
134
  ) => Promise<void>
 
 
 
 
135
  }
136
 
137
- export const useAppStore = create<AppState>((set, get) => ({
138
- ...createOperationSlice(set),
139
- documents: [],
140
- currentDocumentIndex: 0,
141
- scale: 100,
142
- showSegmentationMask: false,
143
- showInpaintedImage: false,
144
- showRenderedImage: false,
145
- showTextBlocksOverlay: false,
146
- mode: 'select',
147
- selectedBlockIndex: undefined,
148
- autoFitEnabled: true,
149
- llmModels: [],
150
- llmSelectedModel: undefined,
151
- llmSelectedLanguage: undefined,
152
- llmReady: false,
153
- llmLoading: false,
154
- operation: undefined,
155
- hydrateDocuments: (docs: Document[]) => {
156
- set({
157
- documents: docs,
158
- currentDocumentIndex: 0,
159
- selectedBlockIndex: undefined,
160
- })
161
- },
162
- openDocuments: async () => {
163
- get().startOperation({ type: 'load-khr', cancellable: false })
164
- try {
165
- const docs: Document[] = await invoke('open_documents')
166
- get().hydrateDocuments(docs)
167
- } finally {
168
- get().finishOperation()
169
- }
170
- },
171
- saveDocuments: async () => {
172
- get().startOperation({ type: 'save-khr', cancellable: false })
173
- try {
174
- await invoke('save_documents')
175
- } finally {
176
- get().finishOperation()
177
- }
178
- },
179
- openExternal: async (url: string) => {
180
- await invoke('open_external', { url })
181
- },
182
- setCurrentDocumentIndex: (index: number) => {
183
- set({ currentDocumentIndex: index, selectedBlockIndex: undefined })
184
- },
185
- setScale: (scale: number) => {
186
- const clamped = Math.max(10, Math.min(100, Math.round(scale)))
187
- set({ scale: clamped })
188
- },
189
- setShowSegmentationMask: (show: boolean) => {
190
- set({ showSegmentationMask: show })
191
- },
192
- setShowInpaintedImage: (show: boolean) => {
193
- set({ showInpaintedImage: show })
194
- },
195
- setShowRenderedImage: (show: boolean) => {
196
- set({ showRenderedImage: show })
197
- },
198
- setShowTextBlocksOverlay: (show: boolean) => {
199
- set({ showTextBlocksOverlay: show })
200
- },
201
- setMode: (mode: ToolMode) => set({ mode }),
202
- setSelectedBlockIndex: (index?: number) => set({ selectedBlockIndex: index }),
203
- setAutoFitEnabled: (enabled: boolean) => set({ autoFitEnabled: enabled }),
204
- updateTextBlocks: async (textBlocks: TextBlock[]) => {
205
- const { documents, currentDocumentIndex } = get()
206
- const doc = documents[currentDocumentIndex]
207
  if (!doc) return
208
- const updatedDoc: Document = {
209
- ...doc,
210
- textBlocks,
211
- }
212
- set({
213
- documents: replaceDocument(documents, currentDocumentIndex, updatedDoc),
214
- })
215
- await textBlockSyncer.enqueue(currentDocumentIndex, textBlocks)
216
- },
217
- invokeWithStatus: async (command: string, args: {}) => {
218
- let ret: Document = await invoke<Document>(command, args)
219
- return ret
220
- },
221
- detect: async (_, index) => {
222
- index = index ?? get().currentDocumentIndex
223
- const doc: Document = await get().invokeWithStatus('detect', {
224
- index,
225
- })
226
- set((state) => ({
227
- documents: replaceDocument(state.documents, index, doc),
228
- showRenderedImage: false, // hide rendered image to show the boxes
229
- }))
230
- },
231
- ocr: async (_, index) => {
232
- index = index ?? get().currentDocumentIndex
233
- const doc: Document = await get().invokeWithStatus('ocr', { index })
234
- set((state) => ({
235
- documents: replaceDocument(state.documents, index, doc),
236
- }))
237
- },
238
- inpaint: async (_, index) => {
239
- index = index ?? get().currentDocumentIndex
240
  await textBlockSyncer.flush()
241
- const doc: Document = await get().invokeWithStatus('inpaint', {
242
- index,
 
 
 
 
 
 
 
 
 
 
 
 
243
  })
244
- set((state) => ({
245
- documents: replaceDocument(state.documents, index, doc),
246
- showInpaintedImage: true,
247
- }))
248
- },
249
- render: async (_, index) => {
250
- index = index ?? get().currentDocumentIndex
251
- await textBlockSyncer.flush()
252
- const doc: Document = await get().invokeWithStatus('render', { index })
253
- set((state) => ({
254
- documents: replaceDocument(state.documents, index, doc),
255
- showRenderedImage: true,
256
- }))
257
- },
258
- llmList: async () => {
259
- try {
260
- const models = await invoke<LlmModelInfo[]>('llm_list')
261
- set({ llmModels: models })
262
- const currentModel = get().llmSelectedModel
263
- const currentLanguage = get().llmSelectedLanguage
264
- const hasCurrent = models.some((model) => model.id === currentModel)
265
- const nextModel = hasCurrent
266
- ? (currentModel ?? models[0]?.id)
267
- : models[0]?.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  const nextLanguage = pickLanguage(
269
- models,
270
- nextModel,
271
- hasCurrent ? currentLanguage : undefined,
272
  )
273
  set({
274
- llmSelectedModel: nextModel,
275
  llmSelectedLanguage: nextLanguage,
 
 
276
  })
277
- } catch (_) {}
278
- },
279
- llmSetSelectedModel: async (id: string) => {
280
- await invoke('llm_offload')
281
- const nextLanguage = pickLanguage(
282
- get().llmModels,
283
- id,
284
- get().llmSelectedLanguage,
285
- )
286
- set({
287
- llmSelectedModel: id,
288
- llmSelectedLanguage: nextLanguage,
289
- llmLoading: false,
290
- llmReady: false,
291
- })
292
- },
293
- llmSetSelectedLanguage: (language: string) => {
294
- const languages = findModelLanguages(
295
- get().llmModels,
296
- get().llmSelectedModel,
297
- )
298
- if (!languages.includes(language)) return
299
- set({ llmSelectedLanguage: language })
300
- },
301
- llmToggleLoadUnload: async () => {
302
- // unload
303
- if (get().llmReady) {
304
- await invoke('llm_offload')
305
- set({ llmLoading: false, llmReady: false })
306
- return
307
- }
308
 
309
- // load
310
- const id = get().llmSelectedModel
311
- if (!id) return
312
- get().startOperation({
313
- type: 'llm-load',
314
- cancellable: false,
315
- })
316
- let ready = false
317
- try {
318
- await invoke('llm_load', { id })
319
-
320
- await get().setProgress(100, ProgressBarStatus.Paused)
321
-
322
- set({ llmLoading: true })
323
- // poll for llmCheckReady and set llmLoading false
324
- let try_time = 0
325
- while (try_time++ < 300) {
326
- await get().llmCheckReady()
327
- if (get().llmReady) {
328
- ready = true
329
- await get().clearProgress()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  set({ llmLoading: false })
331
- break
332
  }
333
- await new Promise((resolve) => setTimeout(resolve, 100))
334
  }
335
- } finally {
336
- if (!ready) {
337
- set({ llmLoading: false })
338
- await get().clearProgress()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  }
340
- get().finishOperation()
341
- }
342
- },
343
- llmCheckReady: async () => {
344
- try {
345
- const ready = await invoke<boolean>('llm_ready')
346
- set({ llmReady: ready })
347
- } catch (_) {}
348
- },
349
- llmGenerate: async (_: any, index?: number, textBlockIndex?: number) => {
350
- index = index ?? get().currentDocumentIndex
351
- console.log(
352
- 'Generating LLM content for document',
353
- index,
354
- 'text block',
355
- textBlockIndex,
356
- )
357
- const languages = findModelLanguages(
358
- get().llmModels,
359
- get().llmSelectedModel,
360
- )
361
- const selectedLanguage = get().llmSelectedLanguage
362
- const language =
363
- languages.length > 0
364
- ? selectedLanguage && languages.includes(selectedLanguage)
365
- ? selectedLanguage
366
- : languages[0]
367
- : undefined
368
- const doc: Document = await get().invokeWithStatus('llm_generate', {
369
- index,
370
- textBlockIndex,
371
- language,
372
- })
373
- set((state) => ({
374
- documents: replaceDocument(state.documents, index, doc),
375
- showTextBlocksOverlay: true,
376
- }))
377
- },
378
-
379
- // batch proceeses
380
- processImage: async (_, index, options) => {
381
- const normalizedOptions =
382
- typeof options === 'function'
383
- ? { onProgress: options, skipOperationTracking: false }
384
- : (options ?? {})
385
 
386
- const { onProgress, skipOperationTracking } = normalizedOptions
387
- const operation = get().operation
388
- const isBatchRun = operation?.type === 'process-all'
389
-
390
- if (!get().llmReady) {
391
- await get().llmList()
392
- await get().llmToggleLoadUnload()
393
- }
394
-
395
- index = index ?? get().currentDocumentIndex
396
- console.log('Processing image at index', index)
397
- const setProgres = onProgress ?? get().setProgress
398
- const shouldTrackOperation = skipOperationTracking !== true && !isBatchRun
399
- const ownsOperation = shouldTrackOperation && !isBatchRun
400
-
401
- const actions = ['detect', 'ocr', 'inpaint', 'llmGenerate'] as const
402
- const totalSteps = actions.length
403
-
404
- if (shouldTrackOperation) {
405
- const firstStep = actions[0] ?? 'detect'
406
- if (ownsOperation) {
407
- get().startOperation({
408
- type: 'process-current',
409
- step: firstStep,
410
- current: 0,
411
- total: totalSteps,
412
- cancellable: true,
413
- })
414
- } else {
415
- get().updateOperation({
416
- step: firstStep,
417
- current: 0,
418
- total: totalSteps,
419
  })
420
  }
421
- }
422
-
423
- await setProgres(0)
424
- for (let i = 0; i < actions.length; i++) {
425
- if (get().operation?.cancelRequested) {
426
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  }
428
 
429
- const action = actions[i]
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  if (shouldTrackOperation) {
432
- get().updateOperation({
433
- step: action,
434
- current: i,
435
- total: totalSteps,
436
- })
 
 
 
 
 
 
 
 
 
 
 
437
  }
438
 
439
- await (get() as any)[actions[i]](_, index)
440
- await setProgres(Math.floor(((i + 1) / totalSteps) * 100))
441
- }
442
-
443
- const cancelled = get().operation?.cancelRequested
444
-
445
- if (shouldTrackOperation && ownsOperation && !cancelled) {
446
- get().updateOperation({ current: totalSteps, total: totalSteps })
447
- }
448
 
449
- if (shouldTrackOperation && ownsOperation) {
450
- get().finishOperation()
451
- }
452
 
453
- if (!onProgress) {
454
- await get().clearProgress()
455
- }
 
 
 
 
456
 
457
- if (cancelled) {
458
- return
459
- }
460
- },
461
 
462
- inpaintAndRenderImage: async (_, index) => {
463
- index = index ?? get().currentDocumentIndex
464
- await get().inpaint(_, index)
465
- await get().render(_, index)
466
- },
467
 
468
- processAllImages: async () => {
469
- const total = get().documents.length
470
- if (!total) return
471
 
472
- if (!get().llmReady) {
473
- await get().llmList()
474
- await get().llmToggleLoadUnload()
475
- }
476
 
477
- get().startOperation({
478
- type: 'process-all',
479
- cancellable: true,
480
- current: 0,
481
- total,
482
- })
483
 
484
- for (let index = 0; index < total; index++) {
485
- if (get().operation?.cancelRequested) break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
 
487
- set({ currentDocumentIndex: index, selectedBlockIndex: undefined })
488
- get().updateOperation({
489
- current: index,
 
490
  total,
491
  })
492
 
493
- await get().processImage(null, index, {
494
- onProgress: async (progress) => {
495
- if (get().operation?.cancelRequested) return
496
- const currentValue = index + progress / 100
497
- const overall = Math.min(
498
- 100,
499
- Math.round((currentValue / total) * 100),
500
- )
501
- await get().setProgress(overall)
502
- get().updateOperation({ current: currentValue, total })
503
- },
504
- skipOperationTracking: true,
505
- })
506
-
507
- if (get().operation?.cancelRequested) {
508
- break
509
- }
510
 
511
- get().updateOperation({ current: index + 1, total })
512
- }
 
 
 
513
 
514
- if (!get().operation?.cancelRequested) {
515
- get().updateOperation({ current: total, total })
516
- }
 
 
 
 
 
 
 
 
 
 
517
 
518
- await get().clearProgress()
519
- get().finishOperation()
520
- },
521
 
522
- exportDocument: async () => {
523
- const index = get().currentDocumentIndex
524
- await invoke('export_document', { index })
525
- },
526
 
527
- exportAllDocuments: async () => {
528
- if (!get().documents.length) return
529
- await invoke('export_all_documents')
530
- },
531
 
532
- setProgress: async (progress?: number, state?: ProgressBarStatus) => {
533
- await getCurrentWindow().setProgressBar({
534
- status: state ?? ProgressBarStatus.Normal,
535
- progress: progress,
536
- })
537
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
 
539
- clearProgress: async () => {
540
- await getCurrentWindow().setProgressBar({
541
- status: ProgressBarStatus.None,
542
- progress: 0,
543
- })
544
- },
545
- }))
 
546
 
547
  type ConfigState = {
548
  maskConfig: {
 
5
  import { getCurrentWindow, ProgressBarStatus } from '@tauri-apps/api/window'
6
  import { Document, TextBlock, ToolMode } from '@/types'
7
  import { createOperationSlice, type OperationSlice } from '@/lib/operations'
8
+ import {
9
+ callOpenAICompletion,
10
+ isOpenAIConfigured,
11
+ isOpenAIModel,
12
+ OPENAI_COMPATIBLE_MODEL,
13
+ OPENAI_DEFAULT_MODEL,
14
+ type LlmModelInfo,
15
+ } from '@/lib/openai'
16
+ import { t } from 'i18next'
17
 
18
  type ProcessImageOptions =
19
  | ((progress: number) => Promise<void>)
 
22
  skipOperationTracking?: boolean
23
  }
24
 
 
 
 
 
 
25
  const replaceDocument = (docs: Document[], index: number, doc: Document) =>
26
  docs.map((item, idx) => (idx === index ? doc : item))
27
 
 
93
  llmSelectedLanguage?: string
94
  llmReady: boolean
95
  llmLoading: boolean
96
+ llmOpenAIEndpoint: string
97
+ llmOpenAIApiKey: string
98
+ llmOpenAIPrompt: string
99
+ llmOpenAIModel: string
100
  // ui + actions
101
  hydrateDocuments: (docs: Document[]) => void
102
  openDocuments: () => Promise<void>
 
112
  setSelectedBlockIndex: (index?: number) => void
113
  setAutoFitEnabled: (enabled: boolean) => void
114
  updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
 
115
  setProgress: (progress?: number, status?: ProgressBarStatus) => Promise<void>
116
  clearProgress: () => Promise<void>
117
  // Processing actions
 
139
  index?: number,
140
  text_block_index?: number,
141
  ) => Promise<void>
142
+ setLlmOpenAIEndpoint: (endpoint: string) => void
143
+ setLlmOpenAIApiKey: (apiKey: string) => void
144
+ setLlmOpenAIPrompt: (prompt: string) => void
145
+ setLlmOpenAIModel: (model: string) => void
146
  }
147
 
148
+ export const useAppStore = create<AppState>((set, get) => {
149
+ const isOpenAICompatible = () => isOpenAIModel(get().llmSelectedModel)
150
+
151
+ const syncOpenAIReady = () => {
152
+ if (!isOpenAICompatible()) return false
153
+ const { llmOpenAIEndpoint, llmOpenAIApiKey } = get()
154
+ const ready = isOpenAIConfigured(llmOpenAIEndpoint, llmOpenAIApiKey)
155
+ set({ llmReady: ready, llmLoading: false })
156
+ return true
157
+ }
158
+
159
+ const generateWithOpenAI = async (
160
+ index: number,
161
+ textBlockIndex?: number,
162
+ ): Promise<Document | void> => {
163
+ const {
164
+ documents,
165
+ llmOpenAIEndpoint,
166
+ llmOpenAIApiKey,
167
+ llmOpenAIPrompt,
168
+ llmOpenAIModel,
169
+ } = get()
170
+ const doc = documents[index]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  if (!doc) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  await textBlockSyncer.flush()
173
+
174
+ const blocks = doc.textBlocks ?? []
175
+ const hasSingle = typeof textBlockIndex === 'number' && textBlockIndex >= 0
176
+ const sourceText = hasSingle
177
+ ? (blocks[textBlockIndex!]?.text ?? '')
178
+ : blocks.map((block) => block.text ?? '').join('\n')
179
+ if (!sourceText.trim()) return
180
+
181
+ const completion = await callOpenAICompletion({
182
+ endpoint: llmOpenAIEndpoint,
183
+ apiKey: llmOpenAIApiKey,
184
+ prompt: llmOpenAIPrompt.trim(),
185
+ content: sourceText,
186
+ model: llmOpenAIModel,
187
  })
188
+
189
+ const translatedLines = hasSingle ? null : completion.split(/\r?\n/)
190
+ const updatedBlocks = blocks.map((block, i) => {
191
+ if (hasSingle) {
192
+ return i === textBlockIndex
193
+ ? { ...block, translation: completion }
194
+ : block
195
+ }
196
+ const translated = translatedLines?.[i]
197
+ if (translated === undefined) return block
198
+ return { ...block, translation: translated }
199
+ })
200
+
201
+ return { ...doc, textBlocks: updatedBlocks }
202
+ }
203
+
204
+ return {
205
+ ...createOperationSlice(set),
206
+ documents: [],
207
+ currentDocumentIndex: 0,
208
+ scale: 100,
209
+ showSegmentationMask: false,
210
+ showInpaintedImage: false,
211
+ showRenderedImage: false,
212
+ showTextBlocksOverlay: false,
213
+ mode: 'select',
214
+ selectedBlockIndex: undefined,
215
+ autoFitEnabled: true,
216
+ llmModels: [],
217
+ llmSelectedModel: undefined,
218
+ llmSelectedLanguage: undefined,
219
+ llmReady: false,
220
+ llmLoading: false,
221
+ llmOpenAIEndpoint: '',
222
+ llmOpenAIApiKey: '',
223
+ llmOpenAIPrompt: t('llm.openaiPromptPlaceholder'),
224
+ llmOpenAIModel: OPENAI_DEFAULT_MODEL,
225
+ operation: undefined,
226
+ hydrateDocuments: (docs: Document[]) => {
227
+ set({
228
+ documents: docs,
229
+ currentDocumentIndex: 0,
230
+ selectedBlockIndex: undefined,
231
+ })
232
+ },
233
+ openDocuments: async () => {
234
+ get().startOperation({ type: 'load-khr', cancellable: false })
235
+ try {
236
+ const docs: Document[] = await invoke('open_documents')
237
+ get().hydrateDocuments(docs)
238
+ } finally {
239
+ get().finishOperation()
240
+ }
241
+ },
242
+ saveDocuments: async () => {
243
+ get().startOperation({ type: 'save-khr', cancellable: false })
244
+ try {
245
+ await invoke('save_documents')
246
+ } finally {
247
+ get().finishOperation()
248
+ }
249
+ },
250
+ openExternal: async (url: string) => {
251
+ await invoke('open_external', { url })
252
+ },
253
+ setCurrentDocumentIndex: (index: number) => {
254
+ set({ currentDocumentIndex: index, selectedBlockIndex: undefined })
255
+ },
256
+ setScale: (scale: number) => {
257
+ const clamped = Math.max(10, Math.min(100, Math.round(scale)))
258
+ set({ scale: clamped })
259
+ },
260
+ setShowSegmentationMask: (show: boolean) => {
261
+ set({ showSegmentationMask: show })
262
+ },
263
+ setShowInpaintedImage: (show: boolean) => {
264
+ set({ showInpaintedImage: show })
265
+ },
266
+ setShowRenderedImage: (show: boolean) => {
267
+ set({ showRenderedImage: show })
268
+ },
269
+ setShowTextBlocksOverlay: (show: boolean) => {
270
+ set({ showTextBlocksOverlay: show })
271
+ },
272
+ setMode: (mode: ToolMode) => set({ mode }),
273
+ setSelectedBlockIndex: (index?: number) =>
274
+ set({ selectedBlockIndex: index }),
275
+ setAutoFitEnabled: (enabled: boolean) => set({ autoFitEnabled: enabled }),
276
+ updateTextBlocks: async (textBlocks: TextBlock[]) => {
277
+ const { documents, currentDocumentIndex } = get()
278
+ const doc = documents[currentDocumentIndex]
279
+ if (!doc) return
280
+ const updatedDoc: Document = {
281
+ ...doc,
282
+ textBlocks,
283
+ }
284
+ set({
285
+ documents: replaceDocument(documents, currentDocumentIndex, updatedDoc),
286
+ })
287
+ await textBlockSyncer.enqueue(currentDocumentIndex, textBlocks)
288
+ },
289
+ detect: async (_, index) => {
290
+ index = index ?? get().currentDocumentIndex
291
+ const doc: Document = await invoke<Document>('detect', {
292
+ index,
293
+ })
294
+ set((state) => ({
295
+ documents: replaceDocument(state.documents, index, doc),
296
+ showRenderedImage: false, // hide rendered image to show the boxes
297
+ }))
298
+ },
299
+ ocr: async (_, index) => {
300
+ index = index ?? get().currentDocumentIndex
301
+ const doc: Document = await invoke<Document>('ocr', { index })
302
+ set((state) => ({
303
+ documents: replaceDocument(state.documents, index, doc),
304
+ }))
305
+ },
306
+ inpaint: async (_, index) => {
307
+ index = index ?? get().currentDocumentIndex
308
+ await textBlockSyncer.flush()
309
+ const doc: Document = await invoke<Document>('inpaint', {
310
+ index,
311
+ })
312
+ set((state) => ({
313
+ documents: replaceDocument(state.documents, index, doc),
314
+ showInpaintedImage: true,
315
+ }))
316
+ },
317
+ render: async (_, index) => {
318
+ index = index ?? get().currentDocumentIndex
319
+ await textBlockSyncer.flush()
320
+ const doc: Document = await invoke<Document>('render', { index })
321
+ set((state) => ({
322
+ documents: replaceDocument(state.documents, index, doc),
323
+ showRenderedImage: true,
324
+ }))
325
+ },
326
+ llmList: async () => {
327
+ try {
328
+ const models = [
329
+ ...(await invoke<LlmModelInfo[]>('llm_list')),
330
+ OPENAI_COMPATIBLE_MODEL,
331
+ ]
332
+ set({ llmModels: models })
333
+ const currentModel = get().llmSelectedModel
334
+ const currentLanguage = get().llmSelectedLanguage
335
+ const hasCurrent = models.some((model) => model.id === currentModel)
336
+ const nextModel = hasCurrent
337
+ ? (currentModel ?? models[0]?.id)
338
+ : models[0]?.id
339
+ const nextLanguage = pickLanguage(
340
+ models,
341
+ nextModel,
342
+ hasCurrent ? currentLanguage : undefined,
343
+ )
344
+ set({
345
+ llmSelectedModel: nextModel,
346
+ llmSelectedLanguage: nextLanguage,
347
+ })
348
+ syncOpenAIReady()
349
+ } catch (_) {}
350
+ },
351
+ llmSetSelectedModel: async (id: string) => {
352
+ await invoke('llm_offload')
353
  const nextLanguage = pickLanguage(
354
+ get().llmModels,
355
+ id,
356
+ get().llmSelectedLanguage,
357
  )
358
  set({
359
+ llmSelectedModel: id,
360
  llmSelectedLanguage: nextLanguage,
361
+ llmLoading: false,
362
+ llmReady: false,
363
  })
364
+ syncOpenAIReady()
365
+ },
366
+ llmSetSelectedLanguage: (language: string) => {
367
+ const languages = findModelLanguages(
368
+ get().llmModels,
369
+ get().llmSelectedModel,
370
+ )
371
+ if (!languages.includes(language)) return
372
+ set({ llmSelectedLanguage: language })
373
+ },
374
+ llmToggleLoadUnload: async () => {
375
+ if (isOpenAICompatible()) {
376
+ syncOpenAIReady()
377
+ return
378
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
+ // unload
381
+ if (get().llmReady) {
382
+ await invoke('llm_offload')
383
+ set({ llmLoading: false, llmReady: false })
384
+ return
385
+ }
386
+
387
+ // load
388
+ const id = get().llmSelectedModel
389
+ if (!id) return
390
+ get().startOperation({
391
+ type: 'llm-load',
392
+ cancellable: false,
393
+ })
394
+ let ready = false
395
+ try {
396
+ await invoke('llm_load', { id })
397
+
398
+ await get().setProgress(100, ProgressBarStatus.Paused)
399
+
400
+ set({ llmLoading: true })
401
+ // poll for llmCheckReady and set llmLoading false
402
+ let try_time = 0
403
+ while (try_time++ < 300) {
404
+ await get().llmCheckReady()
405
+ if (get().llmReady) {
406
+ ready = true
407
+ await get().clearProgress()
408
+ set({ llmLoading: false })
409
+ break
410
+ }
411
+ await new Promise((resolve) => setTimeout(resolve, 100))
412
+ }
413
+ } finally {
414
+ if (!ready) {
415
  set({ llmLoading: false })
416
+ await get().clearProgress()
417
  }
418
+ get().finishOperation()
419
  }
420
+ },
421
+ llmCheckReady: async () => {
422
+ if (syncOpenAIReady()) return
423
+ try {
424
+ const ready = await invoke<boolean>('llm_ready')
425
+ set({ llmReady: ready })
426
+ } catch (_) {}
427
+ },
428
+ llmGenerate: async (_: any, index?: number, textBlockIndex?: number) => {
429
+ index = index ?? get().currentDocumentIndex
430
+ const languages = findModelLanguages(
431
+ get().llmModels,
432
+ get().llmSelectedModel,
433
+ )
434
+ const selectedLanguage = get().llmSelectedLanguage
435
+ const language =
436
+ languages.length > 0
437
+ ? selectedLanguage && languages.includes(selectedLanguage)
438
+ ? selectedLanguage
439
+ : languages[0]
440
+ : undefined
441
+
442
+ let doc: Document | void = undefined
443
+ if (isOpenAICompatible()) {
444
+ if (!syncOpenAIReady()) {
445
+ throw new Error(
446
+ 'Provide an OpenAI compatible endpoint and API key to generate translations.',
447
+ )
448
+ }
449
+ doc = await generateWithOpenAI(index, textBlockIndex)
450
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
+ if (doc === undefined) {
453
+ doc = await invoke<Document>('llm_generate', {
454
+ index,
455
+ textBlockIndex,
456
+ language,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  })
458
  }
459
+ set((state) => ({
460
+ documents: replaceDocument(state.documents, index, doc),
461
+ showTextBlocksOverlay: true,
462
+ }))
463
+ },
464
+ setLlmOpenAIEndpoint: (endpoint: string) => {
465
+ set({ llmOpenAIEndpoint: endpoint })
466
+ syncOpenAIReady()
467
+ },
468
+ setLlmOpenAIApiKey: (apiKey: string) => {
469
+ set({ llmOpenAIApiKey: apiKey })
470
+ syncOpenAIReady()
471
+ },
472
+ setLlmOpenAIPrompt: (prompt: string) => {
473
+ set({ llmOpenAIPrompt: prompt })
474
+ syncOpenAIReady()
475
+ },
476
+ setLlmOpenAIModel: (model: string) => {
477
+ set({ llmOpenAIModel: model })
478
+ syncOpenAIReady()
479
+ },
480
+
481
+ // batch proceeses
482
+ processImage: async (_, index, options) => {
483
+ const openAISelected = isOpenAICompatible()
484
+ const normalizedOptions =
485
+ typeof options === 'function'
486
+ ? { onProgress: options, skipOperationTracking: false }
487
+ : (options ?? {})
488
+
489
+ const { onProgress, skipOperationTracking } = normalizedOptions
490
+ const operation = get().operation
491
+ const isBatchRun = operation?.type === 'process-all'
492
+
493
+ if (!get().llmReady && !openAISelected) {
494
+ await get().llmList()
495
+ await get().llmToggleLoadUnload()
496
+ } else if (openAISelected) {
497
+ syncOpenAIReady()
498
+ if (!get().llmReady) {
499
+ throw new Error(
500
+ 'OpenAI compatible endpoint and API key are required before processing.',
501
+ )
502
+ }
503
  }
504
 
505
+ index = index ?? get().currentDocumentIndex
506
+ console.log('Processing image at index', index)
507
+ const setProgres = onProgress ?? get().setProgress
508
+ const shouldTrackOperation = skipOperationTracking !== true && !isBatchRun
509
+ const ownsOperation = shouldTrackOperation && !isBatchRun
510
+
511
+ const actions: Array<'detect' | 'ocr' | 'inpaint' | 'llmGenerate'> = [
512
+ 'detect',
513
+ 'ocr',
514
+ 'inpaint',
515
+ 'llmGenerate',
516
+ ]
517
+ const totalSteps = actions.length
518
 
519
  if (shouldTrackOperation) {
520
+ const firstStep = actions[0] ?? 'detect'
521
+ if (ownsOperation) {
522
+ get().startOperation({
523
+ type: 'process-current',
524
+ step: firstStep,
525
+ current: 0,
526
+ total: totalSteps,
527
+ cancellable: true,
528
+ })
529
+ } else {
530
+ get().updateOperation({
531
+ step: firstStep,
532
+ current: 0,
533
+ total: totalSteps,
534
+ })
535
+ }
536
  }
537
 
538
+ await setProgres(0)
539
+ for (let i = 0; i < actions.length; i++) {
540
+ if (get().operation?.cancelRequested) {
541
+ break
542
+ }
 
 
 
 
543
 
544
+ const action = actions[i]
 
 
545
 
546
+ if (shouldTrackOperation) {
547
+ get().updateOperation({
548
+ step: action,
549
+ current: i,
550
+ total: totalSteps,
551
+ })
552
+ }
553
 
554
+ await (get() as any)[actions[i]](_, index)
555
+ await setProgres(Math.floor(((i + 1) / totalSteps) * 100))
556
+ }
 
557
 
558
+ const cancelled = get().operation?.cancelRequested
 
 
 
 
559
 
560
+ if (shouldTrackOperation && ownsOperation && !cancelled) {
561
+ get().updateOperation({ current: totalSteps, total: totalSteps })
562
+ }
563
 
564
+ if (shouldTrackOperation && ownsOperation) {
565
+ get().finishOperation()
566
+ }
 
567
 
568
+ if (!onProgress) {
569
+ await get().clearProgress()
570
+ }
 
 
 
571
 
572
+ if (cancelled) {
573
+ return
574
+ }
575
+ },
576
+
577
+ inpaintAndRenderImage: async (_, index) => {
578
+ index = index ?? get().currentDocumentIndex
579
+ await get().inpaint(_, index)
580
+ await get().render(_, index)
581
+ },
582
+
583
+ processAllImages: async () => {
584
+ const total = get().documents.length
585
+ if (!total) return
586
+ const openAISelected = isOpenAICompatible()
587
+
588
+ if (!get().llmReady && !openAISelected) {
589
+ await get().llmList()
590
+ await get().llmToggleLoadUnload()
591
+ } else if (openAISelected) {
592
+ syncOpenAIReady()
593
+ if (!get().llmReady) {
594
+ throw new Error(
595
+ 'OpenAI compatible endpoint and API key are required before processing.',
596
+ )
597
+ }
598
+ }
599
 
600
+ get().startOperation({
601
+ type: 'process-all',
602
+ cancellable: true,
603
+ current: 0,
604
  total,
605
  })
606
 
607
+ for (let index = 0; index < total; index++) {
608
+ if (get().operation?.cancelRequested) break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
 
610
+ set({ currentDocumentIndex: index, selectedBlockIndex: undefined })
611
+ get().updateOperation({
612
+ current: index,
613
+ total,
614
+ })
615
 
616
+ await get().processImage(null, index, {
617
+ onProgress: async (progress) => {
618
+ if (get().operation?.cancelRequested) return
619
+ const currentValue = index + progress / 100
620
+ const overall = Math.min(
621
+ 100,
622
+ Math.round((currentValue / total) * 100),
623
+ )
624
+ await get().setProgress(overall)
625
+ get().updateOperation({ current: currentValue, total })
626
+ },
627
+ skipOperationTracking: true,
628
+ })
629
 
630
+ if (get().operation?.cancelRequested) {
631
+ break
632
+ }
633
 
634
+ get().updateOperation({ current: index + 1, total })
635
+ }
 
 
636
 
637
+ if (!get().operation?.cancelRequested) {
638
+ get().updateOperation({ current: total, total })
639
+ }
 
640
 
641
+ await get().clearProgress()
642
+ get().finishOperation()
643
+ },
644
+
645
+ exportDocument: async () => {
646
+ const index = get().currentDocumentIndex
647
+ await invoke('export_document', { index })
648
+ },
649
+
650
+ exportAllDocuments: async () => {
651
+ if (!get().documents.length) return
652
+ await invoke('export_all_documents')
653
+ },
654
+
655
+ setProgress: async (progress?: number, state?: ProgressBarStatus) => {
656
+ await getCurrentWindow().setProgressBar({
657
+ status: state ?? ProgressBarStatus.Normal,
658
+ progress: progress,
659
+ })
660
+ },
661
 
662
+ clearProgress: async () => {
663
+ await getCurrentWindow().setProgressBar({
664
+ status: ProgressBarStatus.None,
665
+ progress: 0,
666
+ })
667
+ },
668
+ }
669
+ })
670
 
671
  type ConfigState = {
672
  maskConfig: {
ui/public/locales/en-US/translation.json CHANGED
@@ -84,6 +84,15 @@
84
  "idle": "Idle",
85
  "selectPlaceholder": "Select model",
86
  "modelLabel": "Model",
 
 
 
 
 
 
 
 
 
87
  "languageLabel": "Target language",
88
  "languagePlaceholder": "Select language",
89
  "load": "Load",
 
84
  "idle": "Idle",
85
  "selectPlaceholder": "Select model",
86
  "modelLabel": "Model",
87
+ "openaiCompatible": "OpenAI Compatible",
88
+ "openaiEndpointLabel": "OpenAI compatible endpoint",
89
+ "openaiEndpointPlaceholder": "https://api.openai.com/v1",
90
+ "openaiApiKeyLabel": "API key",
91
+ "openaiApiKeyPlaceholder": "sk-...",
92
+ "openaiModelLabel": "Model name",
93
+ "openaiModelPlaceholder": "gpt-4o-mini",
94
+ "openaiPromptLabel": "Prompt",
95
+ "openaiPromptPlaceholder": "Translate the following text to Chinese, retain formatting, do not add extra explanations:\n",
96
  "languageLabel": "Target language",
97
  "languagePlaceholder": "Select language",
98
  "load": "Load",
ui/public/locales/ja-JP/translation.json CHANGED
@@ -84,6 +84,15 @@
84
  "idle": "待機中",
85
  "selectPlaceholder": "モデルを選択",
86
  "modelLabel": "モデル",
 
 
 
 
 
 
 
 
 
87
  "languageLabel": "翻訳先の言語",
88
  "languagePlaceholder": "言語を選択",
89
  "load": "読み込み",
 
84
  "idle": "待機中",
85
  "selectPlaceholder": "モデルを選択",
86
  "modelLabel": "モデル",
87
+ "openaiCompatible": "OpenAI 互換サービス",
88
+ "openaiEndpointLabel": "OpenAI 端点",
89
+ "openaiEndpointPlaceholder": "https://api.openai.com/v1",
90
+ "openaiApiKeyLabel": "API キー",
91
+ "openaiApiKeyPlaceholder": "sk-...",
92
+ "openaiModelLabel": "モデル名",
93
+ "openaiModelPlaceholder": "gpt-4o-mini",
94
+ "openaiPromptLabel": "プロンプト",
95
+ "openaiPromptPlaceholder": "以下のテキストを日本語に翻訳し、フォーマットを保持し、追加の説明を加えないでください:\n",
96
  "languageLabel": "翻訳先の言語",
97
  "languagePlaceholder": "言語を選択",
98
  "load": "読み込み",
ui/public/locales/zh-CN/translation.json CHANGED
@@ -84,6 +84,15 @@
84
  "idle": "空闲",
85
  "selectPlaceholder": "选择模型",
86
  "modelLabel": "模型",
 
 
 
 
 
 
 
 
 
87
  "languageLabel": "目标语言",
88
  "languagePlaceholder": "选择语言",
89
  "load": "加载",
 
84
  "idle": "空闲",
85
  "selectPlaceholder": "选择模型",
86
  "modelLabel": "模型",
87
+ "openaiCompatible": "OpenAI 兼容服务",
88
+ "openaiEndpointLabel": "OpenAI 端点",
89
+ "openaiEndpointPlaceholder": "https://api.openai.com/v1",
90
+ "openaiApiKeyLabel": "API 密钥",
91
+ "openaiApiKeyPlaceholder": "sk-...",
92
+ "openaiModelLabel": "模型名称",
93
+ "openaiModelPlaceholder": "gpt-4o-mini",
94
+ "openaiPromptLabel": "提示词",
95
+ "openaiPromptPlaceholder": "将下列文本翻译为中文,保留格式,不要添加额外说明:\n",
96
  "languageLabel": "目标语言",
97
  "languagePlaceholder": "选择语言",
98
  "load": "加载",
ui/public/locales/zh-TW/translation.json CHANGED
@@ -84,6 +84,15 @@
84
  "idle": "閒置",
85
  "selectPlaceholder": "選擇模型",
86
  "modelLabel": "模型",
 
 
 
 
 
 
 
 
 
87
  "languageLabel": "目標語言",
88
  "languagePlaceholder": "選擇語言",
89
  "load": "載入",
 
84
  "idle": "閒置",
85
  "selectPlaceholder": "選擇模型",
86
  "modelLabel": "模型",
87
+ "openaiCompatible": "OpenAI 兼容服務",
88
+ "openaiEndpointLabel": "OpenAI 端點",
89
+ "openaiEndpointPlaceholder": "https://api.openai.com/v1",
90
+ "openaiApiKeyLabel": "API 密鑰",
91
+ "openaiApiKeyPlaceholder": "sk-...",
92
+ "openaiModelLabel": "模型名稱",
93
+ "openaiModelPlaceholder": "gpt-4o-mini",
94
+ "openaiPromptLabel": "提示詞",
95
+ "openaiPromptPlaceholder": "將下列文本翻譯為繁體中文,保留格式,不要添加額外說明:\n",
96
  "languageLabel": "目標語言",
97
  "languagePlaceholder": "選擇語言",
98
  "load": "載入",