Karrot commited on
Commit
09fe956
·
1 Parent(s): 122f5b0

feat: implement error handling and UI for RPC errors

Browse files
ui/components/ActivityBubble.tsx CHANGED
@@ -2,10 +2,12 @@
2
 
3
  import { type ReactNode, useEffect } from 'react'
4
  import { useTranslation } from 'react-i18next'
 
5
  import { useDownloadStore } from '@/lib/downloads'
6
  import { Button } from '@/components/ui/button'
7
  import { type OperationState } from '@/lib/operations'
8
  import { useOperationStore } from '@/lib/stores/operationStore'
 
9
  import { useDocumentMutations } from '@/lib/query/mutations'
10
 
11
  type TranslateFunc = ReturnType<typeof useTranslation>['t']
@@ -72,6 +74,47 @@ function DownloadCard({
72
  )
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  function OperationCard({
76
  operation,
77
  onCancel,
@@ -204,6 +247,8 @@ function OperationCard({
204
  export function ActivityBubble() {
205
  const { t } = useTranslation()
206
  const operation = useOperationStore((state) => state.operation)
 
 
207
  const { cancelOperation } = useDocumentMutations()
208
  const downloads = useDownloadStore((s) => s.downloads)
209
  const ensureSubscribed = useDownloadStore((s) => s.ensureSubscribed)
@@ -216,10 +261,11 @@ export function ActivityBubble() {
216
  (d) => d.status === 'started' || d.status === 'downloading',
217
  )
218
 
219
- if (!operation && activeDownloads.length === 0) return null
220
 
221
  return (
222
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
 
223
  {operation && (
224
  <OperationCard operation={operation} onCancel={cancelOperation} t={t} />
225
  )}
 
2
 
3
  import { type ReactNode, useEffect } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { CircleXIcon } from 'lucide-react'
6
  import { useDownloadStore } from '@/lib/downloads'
7
  import { Button } from '@/components/ui/button'
8
  import { type OperationState } from '@/lib/operations'
9
  import { useOperationStore } from '@/lib/stores/operationStore'
10
+ import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
11
  import { useDocumentMutations } from '@/lib/query/mutations'
12
 
13
  type TranslateFunc = ReturnType<typeof useTranslation>['t']
 
74
  )
75
  }
76
 
77
+ function ErrorCard({
78
+ message,
79
+ onDismiss,
80
+ t,
81
+ }: {
82
+ message: string
83
+ onDismiss: () => void
84
+ t: TranslateFunc
85
+ }) {
86
+ return (
87
+ <div className='bg-card/95 rounded-2xl border border-red-200/80 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-red-900/80'>
88
+ <div className='flex items-start gap-3'>
89
+ <div className='mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-950/70 dark:text-red-400'>
90
+ <CircleXIcon className='size-4' />
91
+ </div>
92
+ <div className='min-w-0 flex-1'>
93
+ <div className='flex items-start justify-between gap-3'>
94
+ <div className='min-w-0'>
95
+ <div className='text-sm font-semibold text-red-700 dark:text-red-300'>
96
+ {t('errors.title')}
97
+ </div>
98
+ <div className='mt-1 break-words border-l-2 border-red-500 pl-3 text-xs text-red-700/90 dark:text-red-200/90'>
99
+ {message}
100
+ </div>
101
+ </div>
102
+ <Button
103
+ variant='ghost'
104
+ size='icon-xs'
105
+ onClick={onDismiss}
106
+ className='text-red-700 hover:bg-red-50 hover:text-red-800 dark:text-red-300 dark:hover:bg-red-950/60'
107
+ aria-label={t('errors.dismiss')}
108
+ >
109
+ <CircleXIcon className='size-3.5' />
110
+ </Button>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ )
116
+ }
117
+
118
  function OperationCard({
119
  operation,
120
  onCancel,
 
247
  export function ActivityBubble() {
248
  const { t } = useTranslation()
249
  const operation = useOperationStore((state) => state.operation)
250
+ const error = useUiErrorStore((state) => state.error)
251
+ const clearError = useUiErrorStore((state) => state.clearError)
252
  const { cancelOperation } = useDocumentMutations()
253
  const downloads = useDownloadStore((s) => s.downloads)
254
  const ensureSubscribed = useDownloadStore((s) => s.ensureSubscribed)
 
261
  (d) => d.status === 'started' || d.status === 'downloading',
262
  )
263
 
264
+ if (!error && !operation && activeDownloads.length === 0) return null
265
 
266
  return (
267
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
268
+ {error && <ErrorCard message={error.message} onDismiss={clearError} t={t} />}
269
  {operation && (
270
  <OperationCard operation={operation} onCancel={cancelOperation} t={t} />
271
  )}
ui/lib/backend.ts CHANGED
@@ -3,6 +3,7 @@
3
  import { WsRpcClient } from './ws'
4
  import { fileOpen, fileSave } from 'browser-fs-access'
5
  import { toArrayBuffer } from './util'
 
6
  import type { RpcMethodMap, RpcNotificationMap, FileResult } from './rpc-types'
7
 
8
  // --- Singleton client ---
@@ -162,15 +163,25 @@ export async function invoke<M extends keyof RpcMethodMap>(
162
 
163
  // Special file-save flow for save_documents / export_document
164
  if (method === 'save_documents' || method === 'export_document') {
165
- const result = await getClient().invoke<FileResult>(method, params)
166
- const blob = new Blob([toArrayBuffer(result.data)])
167
  try {
168
- await fileSave(blob, { fileName: result.filename })
169
- } catch {}
170
- return undefined as RpcMethodMap[M][1]
 
 
 
 
 
 
 
171
  }
172
 
173
- return getClient().invoke<RpcMethodMap[M][1]>(method, params)
 
 
 
 
 
174
  }
175
 
176
  async function openDocumentsRpc(
 
3
  import { WsRpcClient } from './ws'
4
  import { fileOpen, fileSave } from 'browser-fs-access'
5
  import { toArrayBuffer } from './util'
6
+ import { reportRpcError } from './errors'
7
  import type { RpcMethodMap, RpcNotificationMap, FileResult } from './rpc-types'
8
 
9
  // --- Singleton client ---
 
163
 
164
  // Special file-save flow for save_documents / export_document
165
  if (method === 'save_documents' || method === 'export_document') {
 
 
166
  try {
167
+ const result = await getClient().invoke<FileResult>(method, params)
168
+ const blob = new Blob([toArrayBuffer(result.data)])
169
+ try {
170
+ await fileSave(blob, { fileName: result.filename })
171
+ } catch {}
172
+ return undefined as RpcMethodMap[M][1]
173
+ } catch (error) {
174
+ reportRpcError(method, error)
175
+ throw error
176
+ }
177
  }
178
 
179
+ try {
180
+ return await getClient().invoke<RpcMethodMap[M][1]>(method, params)
181
+ } catch (error) {
182
+ reportRpcError(method, error)
183
+ throw error
184
+ }
185
  }
186
 
187
  async function openDocumentsRpc(
ui/lib/errors.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
4
+
5
+ const SURFACED_RPC_METHODS = new Set([
6
+ 'open_documents',
7
+ 'add_documents',
8
+ 'save_documents',
9
+ 'export_document',
10
+ 'detect',
11
+ 'ocr',
12
+ 'inpaint',
13
+ 'update_inpaint_mask',
14
+ 'update_brush_layer',
15
+ 'inpaint_partial',
16
+ 'render',
17
+ 'update_text_blocks',
18
+ 'llm_load',
19
+ 'llm_offload',
20
+ 'llm_generate',
21
+ 'process',
22
+ ])
23
+
24
+ export const normalizeErrorMessage = (error: unknown) => {
25
+ if (error instanceof Error) {
26
+ return error.message
27
+ }
28
+ if (typeof error === 'string') {
29
+ return error
30
+ }
31
+ return 'Unexpected error'
32
+ }
33
+
34
+ export const reportRpcError = (method: string, error: unknown) => {
35
+ if (!SURFACED_RPC_METHODS.has(method)) return
36
+ const message = normalizeErrorMessage(error)
37
+ useUiErrorStore.getState().showError(message)
38
+ }
ui/lib/stores/uiErrorStore.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+
5
+ type UiError = {
6
+ id: number
7
+ message: string
8
+ }
9
+
10
+ type UiErrorStoreState = {
11
+ error?: UiError
12
+ showError: (message: string) => void
13
+ clearError: () => void
14
+ }
15
+
16
+ const ERROR_AUTO_DISMISS_MS = 8000
17
+
18
+ let dismissTimer: ReturnType<typeof setTimeout> | null = null
19
+
20
+ const clearDismissTimer = () => {
21
+ if (!dismissTimer) return
22
+ clearTimeout(dismissTimer)
23
+ dismissTimer = null
24
+ }
25
+
26
+ export const useUiErrorStore = create<UiErrorStoreState>((set) => ({
27
+ error: undefined,
28
+ showError: (message) => {
29
+ clearDismissTimer()
30
+ set({
31
+ error: {
32
+ id: Date.now(),
33
+ message,
34
+ },
35
+ })
36
+ dismissTimer = setTimeout(() => {
37
+ dismissTimer = null
38
+ set({ error: undefined })
39
+ }, ERROR_AUTO_DISMISS_MS)
40
+ },
41
+ clearError: () => {
42
+ clearDismissTimer()
43
+ set({ error: undefined })
44
+ },
45
+ }))
ui/public/locales/en-US/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "Downloading"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "Settings",
158
  "appearance": "Appearance",
 
153
  "download": {
154
  "title": "Downloading"
155
  },
156
+ "errors": {
157
+ "title": "Error",
158
+ "dismiss": "Dismiss"
159
+ },
160
  "settings": {
161
  "title": "Settings",
162
  "appearance": "Appearance",
ui/public/locales/es-ES/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "Descargando"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "Configuración",
158
  "appearance": "Apariencia",
 
153
  "download": {
154
  "title": "Descargando"
155
  },
156
+ "errors": {
157
+ "title": "Error",
158
+ "dismiss": "Cerrar"
159
+ },
160
  "settings": {
161
  "title": "Configuración",
162
  "appearance": "Apariencia",
ui/public/locales/ja-JP/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "ダウンロード中"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "設定",
158
  "appearance": "外観",
 
153
  "download": {
154
  "title": "ダウンロード中"
155
  },
156
+ "errors": {
157
+ "title": "エラー",
158
+ "dismiss": "閉じる"
159
+ },
160
  "settings": {
161
  "title": "設定",
162
  "appearance": "外観",
ui/public/locales/ru-RU/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "Загрузка"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "Настройки",
158
  "appearance": "Внешний вид",
 
153
  "download": {
154
  "title": "Загрузка"
155
  },
156
+ "errors": {
157
+ "title": "Ошибка",
158
+ "dismiss": "Закрыть"
159
+ },
160
  "settings": {
161
  "title": "Настройки",
162
  "appearance": "Внешний вид",
ui/public/locales/zh-CN/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "下载中"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "设置",
158
  "appearance": "外观",
 
153
  "download": {
154
  "title": "下载中"
155
  },
156
+ "errors": {
157
+ "title": "错误",
158
+ "dismiss": "关闭"
159
+ },
160
  "settings": {
161
  "title": "设置",
162
  "appearance": "外观",
ui/public/locales/zh-TW/translation.json CHANGED
@@ -153,6 +153,10 @@
153
  "download": {
154
  "title": "下載中"
155
  },
 
 
 
 
156
  "settings": {
157
  "title": "設定",
158
  "appearance": "外觀",
 
153
  "download": {
154
  "title": "下載中"
155
  },
156
+ "errors": {
157
+ "title": "錯誤",
158
+ "dismiss": "關閉"
159
+ },
160
  "settings": {
161
  "title": "設定",
162
  "appearance": "外觀",