Mayo commited on
Commit
fe48401
·
1 Parent(s): f8d3d3d

refactor: OpenAPI codegen with utoipa and Orval

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Cargo.lock +41 -57
  2. Cargo.toml +2 -1
  3. bun.lock +255 -12
  4. koharu-core/Cargo.toml +1 -1
  5. koharu-core/src/effect.rs +2 -3
  6. koharu-core/src/font.rs +4 -7
  7. koharu-core/src/lib.rs +4 -7
  8. koharu-core/src/protocol.rs +65 -91
  9. koharu-rpc/Cargo.toml +7 -2
  10. koharu-rpc/src/api.rs +722 -235
  11. koharu-rpc/src/bin/openapi.rs +16 -0
  12. koharu-rpc/src/lib.rs +1 -1
  13. koharu-rpc/src/server.rs +3 -3
  14. koharu-rpc/src/{events.rs → tracker.rs} +38 -171
  15. ui/app/(app)/about/page.tsx +6 -4
  16. ui/app/(app)/settings/page.tsx +9 -311
  17. ui/app/bootstrap/page.tsx +32 -37
  18. ui/app/providers.tsx +44 -279
  19. ui/app/splashscreen/page.tsx +31 -54
  20. ui/components/ActivityBubble.tsx +78 -72
  21. ui/components/AppErrorBoundary.tsx +3 -6
  22. ui/components/MenuBar.tsx +71 -27
  23. ui/components/Navigator.tsx +75 -108
  24. ui/components/canvas/CanvasToolbar.tsx +139 -131
  25. ui/components/canvas/Workspace.tsx +8 -0
  26. ui/components/canvas/canvasViewport.ts +13 -17
  27. ui/components/panels/LayersPanel.tsx +2 -2
  28. ui/components/panels/RenderControlsPanel.tsx +4 -6
  29. ui/components/panels/TextBlocksPanel.tsx +34 -5
  30. ui/hooks/useBlockContextMenu.ts +2 -2
  31. ui/hooks/useBlockDrafting.ts +3 -2
  32. ui/hooks/useBrushLayerDisplay.ts +2 -2
  33. ui/hooks/useCanvasDrawing.ts +285 -0
  34. ui/hooks/useCanvasZoom.ts +7 -4
  35. ui/hooks/useMaskDrawing.ts +85 -309
  36. ui/hooks/useRenderBrushDrawing.ts +33 -251
  37. ui/hooks/useRpcConnection.ts +0 -12
  38. ui/hooks/useTextBlocks.ts +129 -11
  39. ui/lib/api.ts +0 -822
  40. ui/lib/api/documents/documents.ts +406 -0
  41. ui/lib/api/downloads/downloads.ts +123 -0
  42. ui/lib/api/exports/exports.ts +217 -0
  43. ui/lib/api/fetch.ts +14 -0
  44. ui/lib/api/jobs/jobs.ts +348 -0
  45. ui/lib/api/llm/llm.ts +423 -0
  46. ui/lib/api/processing/processing.ts +347 -0
  47. ui/lib/api/providers/providers.ts +194 -0
  48. ui/lib/api/regions/regions.ts +224 -0
  49. ui/lib/api/schemas/apiError.ts +11 -0
  50. ui/lib/api/schemas/apiKeyResponse.ts +10 -0
Cargo.lock CHANGED
@@ -216,28 +216,6 @@ dependencies = [
216
  "stable_deref_trait",
217
  ]
218
 
219
- [[package]]
220
- name = "async-stream"
221
- version = "0.3.6"
222
- source = "registry+https://github.com/rust-lang/crates.io-index"
223
- checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
224
- dependencies = [
225
- "async-stream-impl",
226
- "futures-core",
227
- "pin-project-lite",
228
- ]
229
-
230
- [[package]]
231
- name = "async-stream-impl"
232
- version = "0.3.6"
233
- source = "registry+https://github.com/rust-lang/crates.io-index"
234
- checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
235
- dependencies = [
236
- "proc-macro2",
237
- "quote",
238
- "syn 2.0.117",
239
- ]
240
-
241
  [[package]]
242
  name = "async-trait"
243
  version = "0.1.89"
@@ -3996,7 +3974,7 @@ dependencies = [
3996
  "serde_json",
3997
  "strum",
3998
  "tokio",
3999
- "ts-rs",
4000
  "uuid",
4001
  ]
4002
 
@@ -4114,10 +4092,8 @@ name = "koharu-rpc"
4114
  version = "0.41.4"
4115
  dependencies = [
4116
  "anyhow",
4117
- "async-stream",
4118
  "axum",
4119
  "base64 0.22.1",
4120
- "futures",
4121
  "image",
4122
  "imageproc",
4123
  "koharu-app",
@@ -4128,10 +4104,13 @@ dependencies = [
4128
  "rmp-serde",
4129
  "rmpv",
4130
  "serde",
 
4131
  "serde_json",
4132
  "tokio",
4133
  "tower-http",
4134
  "tracing",
 
 
4135
  ]
4136
 
4137
  [[package]]
@@ -7828,15 +7807,6 @@ dependencies = [
7828
  "utf-8",
7829
  ]
7830
 
7831
- [[package]]
7832
- name = "termcolor"
7833
- version = "1.4.1"
7834
- source = "registry+https://github.com/rust-lang/crates.io-index"
7835
- checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
7836
- dependencies = [
7837
- "winapi-util",
7838
- ]
7839
-
7840
  [[package]]
7841
  name = "thiserror"
7842
  version = "1.0.69"
@@ -8376,29 +8346,6 @@ version = "0.2.5"
8376
  source = "registry+https://github.com/rust-lang/crates.io-index"
8377
  checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
8378
 
8379
- [[package]]
8380
- name = "ts-rs"
8381
- version = "12.0.1"
8382
- source = "registry+https://github.com/rust-lang/crates.io-index"
8383
- checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
8384
- dependencies = [
8385
- "serde_json",
8386
- "thiserror 2.0.18",
8387
- "ts-rs-macros",
8388
- ]
8389
-
8390
- [[package]]
8391
- name = "ts-rs-macros"
8392
- version = "12.0.1"
8393
- source = "registry+https://github.com/rust-lang/crates.io-index"
8394
- checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
8395
- dependencies = [
8396
- "proc-macro2",
8397
- "quote",
8398
- "syn 2.0.117",
8399
- "termcolor",
8400
- ]
8401
-
8402
  [[package]]
8403
  name = "ttf-parser"
8404
  version = "0.21.1"
@@ -8678,6 +8625,43 @@ version = "0.2.2"
8678
  source = "registry+https://github.com/rust-lang/crates.io-index"
8679
  checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
8680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8681
  [[package]]
8682
  name = "uuid"
8683
  version = "1.23.0"
 
216
  "stable_deref_trait",
217
  ]
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  [[package]]
220
  name = "async-trait"
221
  version = "0.1.89"
 
3974
  "serde_json",
3975
  "strum",
3976
  "tokio",
3977
+ "utoipa",
3978
  "uuid",
3979
  ]
3980
 
 
4092
  version = "0.41.4"
4093
  dependencies = [
4094
  "anyhow",
 
4095
  "axum",
4096
  "base64 0.22.1",
 
4097
  "image",
4098
  "imageproc",
4099
  "koharu-app",
 
4104
  "rmp-serde",
4105
  "rmpv",
4106
  "serde",
4107
+ "serde_bytes",
4108
  "serde_json",
4109
  "tokio",
4110
  "tower-http",
4111
  "tracing",
4112
+ "utoipa",
4113
+ "utoipa-axum",
4114
  ]
4115
 
4116
  [[package]]
 
7807
  "utf-8",
7808
  ]
7809
 
 
 
 
 
 
 
 
 
 
7810
  [[package]]
7811
  name = "thiserror"
7812
  version = "1.0.69"
 
8346
  source = "registry+https://github.com/rust-lang/crates.io-index"
8347
  checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
8348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8349
  [[package]]
8350
  name = "ttf-parser"
8351
  version = "0.21.1"
 
8625
  source = "registry+https://github.com/rust-lang/crates.io-index"
8626
  checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
8627
 
8628
+ [[package]]
8629
+ name = "utoipa"
8630
+ version = "5.4.0"
8631
+ source = "registry+https://github.com/rust-lang/crates.io-index"
8632
+ checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
8633
+ dependencies = [
8634
+ "indexmap 2.13.0",
8635
+ "serde",
8636
+ "serde_json",
8637
+ "utoipa-gen",
8638
+ ]
8639
+
8640
+ [[package]]
8641
+ name = "utoipa-axum"
8642
+ version = "0.2.0"
8643
+ source = "registry+https://github.com/rust-lang/crates.io-index"
8644
+ checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0"
8645
+ dependencies = [
8646
+ "axum",
8647
+ "paste",
8648
+ "tower-layer",
8649
+ "tower-service",
8650
+ "utoipa",
8651
+ ]
8652
+
8653
+ [[package]]
8654
+ name = "utoipa-gen"
8655
+ version = "5.4.0"
8656
+ source = "registry+https://github.com/rust-lang/crates.io-index"
8657
+ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
8658
+ dependencies = [
8659
+ "proc-macro2",
8660
+ "quote",
8661
+ "regex",
8662
+ "syn 2.0.117",
8663
+ ]
8664
+
8665
  [[package]]
8666
  name = "uuid"
8667
  version = "1.23.0"
Cargo.toml CHANGED
@@ -126,7 +126,8 @@ rmcp = { version = "0.16", features = [
126
  "transport-streamable-http-server",
127
  ] }
128
  schemars = "1.2"
129
- ts-rs = { version = "12.0", features = ["serde-json-impl"] }
 
130
  uuid = { version = "1.18", features = ["v4", "serde"] }
131
  base64 = "0.22"
132
  tower-http = { version = "0.6", features = ["cors"] }
 
126
  "transport-streamable-http-server",
127
  ] }
128
  schemars = "1.2"
129
+ utoipa = { version = "5", features = ["axum_extras"] }
130
+ utoipa-axum = "0.2"
131
  uuid = { version = "1.18", features = ["v4", "serde"] }
132
  base64 = "0.22"
133
  tower-http = { version = "0.6", features = ["cors"] }
bun.lock CHANGED
@@ -29,23 +29,22 @@
29
  "@radix-ui/react-switch": "^1.2.6",
30
  "@radix-ui/react-tabs": "^1.1.13",
31
  "@radix-ui/react-tooltip": "^1.2.8",
32
- "@tanstack/query-sync-storage-persister": "^5.96.1",
33
  "@tanstack/react-query": "^5.96.1",
34
- "@tanstack/react-query-persist-client": "^5.96.1",
35
  "@tanstack/react-virtual": "^3.13.23",
36
  "@tauri-apps/api": "^2.10.1",
37
  "@use-gesture/react": "^10.3.1",
 
38
  "browser-fs-access": "^0.38.0",
39
  "class-variance-authority": "^0.7.1",
40
  "clsx": "^2.1.1",
41
  "i18next": "^25.10.10",
42
  "i18next-browser-languagedetector": "^8.2.1",
43
  "i18next-localstorage-backend": "^4.3.1",
 
44
  "lucide-react": "^0.577.0",
45
  "motion": "^12.38.0",
46
  "next": "^16.2.2",
47
  "next-themes": "^0.4.6",
48
- "p-queue": "^9.1.1",
49
  "react": "^19.2.4",
50
  "react-colorful": "^5.6.1",
51
  "react-dom": "^19.2.4",
@@ -57,6 +56,7 @@
57
  "react-rnd": "^10.5.3",
58
  "remark-gfm": "^4.0.1",
59
  "tailwind-merge": "^3.5.0",
 
60
  "zod": "^4.3.6",
61
  "zustand": "^5.0.12",
62
  },
@@ -66,6 +66,7 @@
66
  "@types/react": "^19.2.14",
67
  "@types/react-dom": "^19.2.3",
68
  "babel-plugin-react-compiler": "^1.0.0",
 
69
  "tailwindcss": "^4.2.2",
70
  "tw-animate-css": "^1.4.0",
71
  "typescript": "^6.0.2",
@@ -83,8 +84,62 @@
83
 
84
  "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
85
 
 
 
86
  "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
89
 
90
  "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
@@ -93,6 +148,8 @@
93
 
94
  "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
95
 
 
 
96
  "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
97
 
98
  "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -173,6 +230,34 @@
173
 
174
  "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA=="],
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
177
 
178
  "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -257,8 +342,28 @@
257
 
258
  "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
259
 
 
 
 
 
 
 
 
 
 
 
260
  "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
261
 
 
 
 
 
 
 
 
 
 
 
262
  "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
263
 
264
  "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -295,14 +400,8 @@
295
 
296
  "@tanstack/query-core": ["@tanstack/query-core@5.96.1", "", {}, "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg=="],
297
 
298
- "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.96.1", "", { "dependencies": { "@tanstack/query-core": "5.96.1" } }, "sha512-61jJdGjhBaAJiQ6TRt4uFI8EoAPyf7bJjbtc59AxZwMyid9kV9FlUEtNs2TkEzYHPTYvDZzz0qOvvkn1YD/VEg=="],
299
-
300
- "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.96.1", "", { "dependencies": { "@tanstack/query-core": "5.96.1", "@tanstack/query-persist-client-core": "5.96.1" } }, "sha512-DpeEVOA8AmUlt+D+X7zZCgfb+xwXj9aJBhALlaFE9uWm0btHT5Xc+GGMnDdtwkxWZLNyEuVarudLArIBsT8N+Q=="],
301
-
302
  "@tanstack/react-query": ["@tanstack/react-query@5.96.1", "", { "dependencies": { "@tanstack/query-core": "5.96.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA=="],
303
 
304
- "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.96.1", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.96.1" }, "peerDependencies": { "@tanstack/react-query": "^5.96.1", "react": "^18 || ^19" } }, "sha512-HkYAI7T2uc12gvVR8P1D7dHgA/AdGDhqDN1HeaNP561OFa0H425qk61ykWtpF8Vnv82ElX6ao0nuNr1oPh89Uw=="],
305
-
306
  "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="],
307
 
308
  "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="],
@@ -359,14 +458,36 @@
359
 
360
  "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
363
 
364
  "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
365
 
366
  "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
367
 
 
 
368
  "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
369
 
 
 
 
 
370
  "browser-fs-access": ["browser-fs-access@0.38.0", "", {}, "sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw=="],
371
 
372
  "caniuse-lite": ["caniuse-lite@1.0.30001752", "", {}, "sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g=="],
@@ -381,6 +502,8 @@
381
 
382
  "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
383
 
 
 
384
  "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
385
 
386
  "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -389,6 +512,10 @@
389
 
390
  "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
391
 
 
 
 
 
392
  "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
393
 
394
  "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -407,20 +534,40 @@
407
 
408
  "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
409
 
 
 
 
 
 
 
410
  "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
411
 
412
  "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
413
 
414
- "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
415
 
416
  "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
417
 
418
  "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
419
 
 
 
 
 
 
 
 
 
420
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
421
 
 
 
 
 
422
  "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
423
 
 
 
424
  "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
425
 
426
  "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -441,6 +588,10 @@
441
 
442
  "git-cliff-windows-x64": ["git-cliff-windows-x64@2.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jskb3nyVGr4dekHSCDM/J6iho45t37wnmMGkPNq42kOoUp04JS96yMBrNRdXfXV9ViZsaZq3NaNu1e3QkhFlyA=="],
443
 
 
 
 
 
444
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
445
 
446
  "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
@@ -459,6 +610,10 @@
459
 
460
  "i18next-localstorage-backend": ["i18next-localstorage-backend@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.28.4" } }, "sha512-ry8WNBanUs55rsRZs9+xaZWRxCoTEMMOf+2vNSfzzJqDPbHaf0eMFMrtYp/2ocxU6Xrxfwz17Fdz0rSs3Kw39Q=="],
461
 
 
 
 
 
462
  "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
463
 
464
  "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
@@ -467,8 +622,16 @@
467
 
468
  "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
469
 
 
 
 
 
470
  "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
471
 
 
 
 
 
472
  "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
473
 
474
  "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
@@ -481,6 +644,16 @@
481
 
482
  "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
483
 
 
 
 
 
 
 
 
 
 
 
484
  "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
485
 
486
  "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -505,14 +678,22 @@
505
 
506
  "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
507
 
 
 
 
 
508
  "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
509
 
510
  "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
511
 
512
  "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
513
 
 
 
514
  "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
515
 
 
 
516
  "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
517
 
518
  "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
@@ -545,6 +726,10 @@
545
 
546
  "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
547
 
 
 
 
 
548
  "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
549
 
550
  "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -601,6 +786,10 @@
601
 
602
  "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
603
 
 
 
 
 
604
  "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
605
 
606
  "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
@@ -619,9 +808,11 @@
619
 
620
  "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
621
 
622
- "p-queue": ["p-queue@9.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-yQS1vV2V7Q14MQrgD8jMNY5owPuGgVHVdSK8NqmKpOVajnjbaeMa6uLOzTALPtvJ7Vo4bw0BGsw7qfUT8z24Ig=="],
623
 
624
- "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
 
 
625
 
626
  "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
627
 
@@ -629,8 +820,12 @@
629
 
630
  "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
631
 
 
 
632
  "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
633
 
 
 
634
  "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
635
 
636
  "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
@@ -647,6 +842,10 @@
647
 
648
  "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
649
 
 
 
 
 
650
  "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="],
651
 
652
  "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -677,6 +876,8 @@
677
 
678
  "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
679
 
 
 
680
  "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
681
 
682
  "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@@ -685,6 +886,14 @@
685
 
686
  "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
687
 
 
 
 
 
 
 
 
 
688
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
689
 
690
  "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -697,12 +906,18 @@
697
 
698
  "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
699
 
 
 
700
  "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
701
 
702
  "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
703
 
 
 
704
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
705
 
 
 
706
  "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
707
 
708
  "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
@@ -717,16 +932,28 @@
717
 
718
  "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
719
 
 
 
720
  "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
721
 
722
  "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
723
 
 
 
724
  "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
725
 
726
  "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
727
 
 
 
 
 
 
 
728
  "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
729
 
 
 
730
  "ui": ["ui@workspace:ui"],
731
 
732
  "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -745,8 +972,12 @@
745
 
746
  "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
747
 
 
 
748
  "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
749
 
 
 
750
  "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
751
 
752
  "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
@@ -759,6 +990,12 @@
759
 
760
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
761
 
 
 
 
 
 
 
762
  "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
763
 
764
  "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -783,6 +1020,10 @@
783
 
784
  "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
785
 
 
 
 
 
786
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
787
 
788
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -795,6 +1036,8 @@
795
 
796
  "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
797
 
 
 
798
  "i18next-browser-languagedetector/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
799
 
800
  "i18next-localstorage-backend/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
 
29
  "@radix-ui/react-switch": "^1.2.6",
30
  "@radix-ui/react-tabs": "^1.1.13",
31
  "@radix-ui/react-tooltip": "^1.2.8",
 
32
  "@tanstack/react-query": "^5.96.1",
 
33
  "@tanstack/react-virtual": "^3.13.23",
34
  "@tauri-apps/api": "^2.10.1",
35
  "@use-gesture/react": "^10.3.1",
36
+ "@xstate/react": "^6.1.0",
37
  "browser-fs-access": "^0.38.0",
38
  "class-variance-authority": "^0.7.1",
39
  "clsx": "^2.1.1",
40
  "i18next": "^25.10.10",
41
  "i18next-browser-languagedetector": "^8.2.1",
42
  "i18next-localstorage-backend": "^4.3.1",
43
+ "immer": "^11.1.4",
44
  "lucide-react": "^0.577.0",
45
  "motion": "^12.38.0",
46
  "next": "^16.2.2",
47
  "next-themes": "^0.4.6",
 
48
  "react": "^19.2.4",
49
  "react-colorful": "^5.6.1",
50
  "react-dom": "^19.2.4",
 
56
  "react-rnd": "^10.5.3",
57
  "remark-gfm": "^4.0.1",
58
  "tailwind-merge": "^3.5.0",
59
+ "xstate": "^5.30.0",
60
  "zod": "^4.3.6",
61
  "zustand": "^5.0.12",
62
  },
 
66
  "@types/react": "^19.2.14",
67
  "@types/react-dom": "^19.2.3",
68
  "babel-plugin-react-compiler": "^1.0.0",
69
+ "orval": "^8.6.2",
70
  "tailwindcss": "^4.2.2",
71
  "tw-animate-css": "^1.4.0",
72
  "typescript": "^6.0.2",
 
84
 
85
  "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
86
 
87
+ "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
88
+
89
  "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
90
 
91
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="],
92
+
93
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="],
94
+
95
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="],
96
+
97
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="],
98
+
99
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="],
100
+
101
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="],
102
+
103
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="],
104
+
105
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="],
106
+
107
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="],
108
+
109
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="],
110
+
111
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="],
112
+
113
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="],
114
+
115
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="],
116
+
117
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="],
118
+
119
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="],
120
+
121
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="],
122
+
123
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="],
124
+
125
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="],
126
+
127
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="],
128
+
129
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="],
130
+
131
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="],
132
+
133
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="],
134
+
135
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="],
136
+
137
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="],
138
+
139
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="],
140
+
141
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="],
142
+
143
  "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
144
 
145
  "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
 
148
 
149
  "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
150
 
151
+ "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
152
+
153
  "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
154
 
155
  "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
 
230
 
231
  "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA=="],
232
 
233
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
234
+
235
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
236
+
237
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
238
+
239
+ "@orval/angular": ["@orval/angular@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2" } }, "sha512-XNTYgfbsjnyGWG12jKkC5URFILTRUASOizypqDLKJWxA3ZEJ6/Dwnuz4wgdiTxb8Ltv1IyuJABkkxk2OM8eCMg=="],
240
+
241
+ "@orval/axios": ["@orval/axios@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2" } }, "sha512-76xSbiwqbYNbM1pasbPv2fSo2Dmwe879HnAWi406GSHAmwlDu5qCN+E53vsvceidUWWuMjh/JuEWgptx/tPGBQ=="],
242
+
243
+ "@orval/core": ["@orval/core@8.6.2", "", { "dependencies": { "@scalar/openapi-types": "0.5.3", "acorn": "^8.15.0", "compare-versions": "^6.1.1", "debug": "^4.4.3", "esbuild": "^0.27.3", "esutils": "2.0.3", "fs-extra": "^11.3.2", "globby": "16.1.0", "jiti": "^2.6.1", "remeda": "^2.33.6", "typedoc": "^0.28.17" }, "peerDependencies": { "@faker-js/faker": ">=10" }, "optionalPeers": ["@faker-js/faker"] }, "sha512-spwJFnqyEm96R7pt+DXgpqmOA2g1iJVN8R/xhub8z6uZVvxEsRMWxZs93Idk0fpZOFkfmq+d6LuZCd5/lbIzCg=="],
244
+
245
+ "@orval/fetch": ["@orval/fetch@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@scalar/openapi-types": "0.5.3" } }, "sha512-MBQ88xSQmx+5hAirCBiHSYL/rtDy6T3e7eAXaCGZ8trjeWryGpW9hF+dJukFw0QmWL8ql6ez0O9VnRkEDeVYhQ=="],
246
+
247
+ "@orval/hono": ["@orval/hono@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@orval/zod": "8.6.2", "fs-extra": "^11.3.2", "remeda": "^2.33.6" } }, "sha512-zv3ZomWvGuVqSoWIUujA6Z0rpxGo9g0dA58+tcPCRaxBIq6ld1YYhLea9QVE96xvb3RfFrOYhGgS840/yVpwyQ=="],
248
+
249
+ "@orval/mcp": ["@orval/mcp@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@orval/fetch": "8.6.2", "@orval/zod": "8.6.2" } }, "sha512-iw4opul8nI97y4bpoTzQS611HefBEQUPfnLS4RCilQyi23mCH0/Ee60USHf7aUeBqdp5yboyWOZxNRLr2T/fuQ=="],
250
+
251
+ "@orval/mock": ["@orval/mock@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "remeda": "^2.33.6" } }, "sha512-5/21u5RdROmXyzpuBzj1+sbMwP+YgLuzR2YDxx7I9nNZd/IB77o5mZyzaPXxmZ5AG4ONQJ/07gBsapxmhQrT8A=="],
252
+
253
+ "@orval/query": ["@orval/query@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@orval/fetch": "8.6.2", "remeda": "^2.33.6" } }, "sha512-cyIqk25w6R8VehCGZT2KunOpen3V1qKVqJy1Jn+oDkPDDr9aGAU5f1ohBqNQuL4/bowYmzay0VApD73PqIjbgA=="],
254
+
255
+ "@orval/solid-start": ["@orval/solid-start@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@scalar/openapi-types": "0.5.3" } }, "sha512-FAskL7OhN0/oV2S5sEA4gz6J8Cp+9GpqTfwQTeZAJ7Iq1wH6xzQBSkjhuJ0pveSrONGTDLj6PEO3g0NqP/AakQ=="],
256
+
257
+ "@orval/swr": ["@orval/swr@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "@orval/fetch": "8.6.2" } }, "sha512-vQmE3AwQE45PxMXYyuHLIXd7NjDKmJNlCg4VSeO0hznAAwPhubussCrFDeq/x6QbnBp/IOUBZh2SSf85W/AsPQ=="],
258
+
259
+ "@orval/zod": ["@orval/zod@8.6.2", "", { "dependencies": { "@orval/core": "8.6.2", "remeda": "^2.33.6" } }, "sha512-bVEXzO7W8kXtRNzTDoN+Pq8NVJnYGS+P4PbqpO5k56rWiTqEsYKzQU0vR2jHOeao2RGoIF3OiX3qMrhfak5GfA=="],
260
+
261
  "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
262
 
263
  "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
 
342
 
343
  "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
344
 
345
+ "@scalar/helpers": ["@scalar/helpers@0.2.18", "", {}, "sha512-w1d4tpNEVZ293oB2BAgLrS0kVPUtG3eByNmOCJA5eK9vcT4D3cmsGtWjUaaqit0BQCsBFHK51rasGvSWnApYTw=="],
346
+
347
+ "@scalar/json-magic": ["@scalar/json-magic@0.11.7", "", { "dependencies": { "@scalar/helpers": "0.2.18", "pathe": "^2.0.3", "yaml": "^2.8.0" } }, "sha512-GVz9E0vXu+ecypkdn0biK1gbQVkK4QTTX1Hq3eMgxlLQC91wwiqWfCqwfhuX0LRu+Z5OmYhLhufDJEEh56rVgA=="],
348
+
349
+ "@scalar/openapi-parser": ["@scalar/openapi-parser@0.24.17", "", { "dependencies": { "@scalar/helpers": "0.2.18", "@scalar/json-magic": "0.11.7", "@scalar/openapi-types": "0.5.4", "@scalar/openapi-upgrader": "0.1.11", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-aM9UVrzlMreC3X/sZbyj+7XDZmat3ecGC3RpU8dqEO/HIH+CEX0xMLuP+41DhePCYg5+9TtDomSfWuMq4x1Z1A=="],
350
+
351
+ "@scalar/openapi-types": ["@scalar/openapi-types@0.5.3", "", { "dependencies": { "zod": "^4.1.11" } }, "sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw=="],
352
+
353
+ "@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.11", "", { "dependencies": { "@scalar/openapi-types": "0.5.4" } }, "sha512-ngJcHGoCHmpWgYtNy08vmzFfLdQEkMpvaCQqNPPMNKq0QEXOv89e/rn+TZJZgPnRlY7fDIoIhn9lNgr+azBW+w=="],
354
+
355
  "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
356
 
357
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
358
+
359
+ "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
360
+
361
+ "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
362
+
363
+ "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
364
+
365
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
366
+
367
  "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
368
 
369
  "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
 
400
 
401
  "@tanstack/query-core": ["@tanstack/query-core@5.96.1", "", {}, "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg=="],
402
 
 
 
 
 
403
  "@tanstack/react-query": ["@tanstack/react-query@5.96.1", "", { "dependencies": { "@tanstack/query-core": "5.96.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA=="],
404
 
 
 
405
  "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="],
406
 
407
  "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="],
 
458
 
459
  "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
460
 
461
+ "@xstate/react": ["@xstate/react@6.1.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "xstate": "^5.28.0" }, "optionalPeers": ["xstate"] }, "sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw=="],
462
+
463
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
464
+
465
+ "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
466
+
467
+ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
468
+
469
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
470
+
471
+ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
472
+
473
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
474
+
475
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
476
+
477
  "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
478
 
479
  "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
480
 
481
  "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
482
 
483
+ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
484
+
485
  "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
486
 
487
+ "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
488
+
489
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
490
+
491
  "browser-fs-access": ["browser-fs-access@0.38.0", "", {}, "sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw=="],
492
 
493
  "caniuse-lite": ["caniuse-lite@1.0.30001752", "", {}, "sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g=="],
 
502
 
503
  "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
504
 
505
+ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
506
+
507
  "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
508
 
509
  "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
 
512
 
513
  "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
514
 
515
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
516
+
517
+ "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
518
+
519
  "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
520
 
521
  "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
 
534
 
535
  "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
536
 
537
+ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
538
+
539
+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
540
+
541
+ "esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="],
542
+
543
  "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
544
 
545
  "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
546
 
547
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
548
 
549
  "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
550
 
551
  "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
552
 
553
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
554
+
555
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
556
+
557
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
558
+
559
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
560
+
561
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
562
 
563
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
564
+
565
+ "find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
566
+
567
  "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
568
 
569
+ "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
570
+
571
  "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
572
 
573
  "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
 
588
 
589
  "git-cliff-windows-x64": ["git-cliff-windows-x64@2.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jskb3nyVGr4dekHSCDM/J6iho45t37wnmMGkPNq42kOoUp04JS96yMBrNRdXfXV9ViZsaZq3NaNu1e3QkhFlyA=="],
590
 
591
+ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
592
+
593
+ "globby": ["globby@16.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ=="],
594
+
595
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
596
 
597
  "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
 
610
 
611
  "i18next-localstorage-backend": ["i18next-localstorage-backend@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.28.4" } }, "sha512-ry8WNBanUs55rsRZs9+xaZWRxCoTEMMOf+2vNSfzzJqDPbHaf0eMFMrtYp/2ocxU6Xrxfwz17Fdz0rSs3Kw39Q=="],
612
 
613
+ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
614
+
615
+ "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
616
+
617
  "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
618
 
619
  "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
 
622
 
623
  "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
624
 
625
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
626
+
627
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
628
+
629
  "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
630
 
631
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
632
+
633
+ "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="],
634
+
635
  "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
636
 
637
  "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
 
644
 
645
  "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
646
 
647
+ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
648
+
649
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
650
+
651
+ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
652
+
653
+ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
654
+
655
+ "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
656
+
657
  "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
658
 
659
  "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
 
678
 
679
  "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
680
 
681
+ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
682
+
683
+ "locate-path": ["locate-path@8.0.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg=="],
684
+
685
  "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
686
 
687
  "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
688
 
689
  "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
690
 
691
+ "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
692
+
693
  "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
694
 
695
+ "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
696
+
697
  "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
698
 
699
  "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
 
726
 
727
  "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
728
 
729
+ "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
730
+
731
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
732
+
733
  "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
734
 
735
  "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
 
786
 
787
  "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
788
 
789
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
790
+
791
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
792
+
793
  "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
794
 
795
  "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
 
808
 
809
  "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
810
 
811
+ "orval": ["orval@8.6.2", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.6.2", "@orval/axios": "8.6.2", "@orval/core": "8.6.2", "@orval/fetch": "8.6.2", "@orval/hono": "8.6.2", "@orval/mcp": "8.6.2", "@orval/mock": "8.6.2", "@orval/query": "8.6.2", "@orval/solid-start": "8.6.2", "@orval/swr": "8.6.2", "@orval/zod": "8.6.2", "@scalar/json-magic": "^0.11.5", "@scalar/openapi-parser": "^0.24.13", "@scalar/openapi-types": "0.5.3", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-hnWdF7jrdgZFhRy4ZVMpLMYpkmfLDkO1wTh881Ll0w18XOmvJauYvrsTmTby3gf4qEChtWxNULI3HlY173NqvA=="],
812
 
813
+ "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
814
+
815
+ "p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
816
 
817
  "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
818
 
 
820
 
821
  "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
822
 
823
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
824
+
825
  "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
826
 
827
+ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
828
+
829
  "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
830
 
831
  "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
 
842
 
843
  "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
844
 
845
+ "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
846
+
847
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
848
+
849
  "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="],
850
 
851
  "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
 
876
 
877
  "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
878
 
879
+ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
880
+
881
  "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
882
 
883
  "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
 
886
 
887
  "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
888
 
889
+ "remeda": ["remeda@2.33.7", "", {}, "sha512-cXlyjevWx5AcslOUEETG4o8XYi9UkoCXcJmj7XhPFVbla+ITuOBxv6ijBrmbeg+ZhzmDThkNdO+iXKUfrJep1w=="],
890
+
891
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
892
+
893
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
894
+
895
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
896
+
897
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
898
 
899
  "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
 
906
 
907
  "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
908
 
909
+ "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
910
+
911
  "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
912
 
913
  "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
914
 
915
+ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
916
+
917
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
918
 
919
+ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
920
+
921
  "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
922
 
923
  "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
 
932
 
933
  "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
934
 
935
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
936
+
937
  "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
938
 
939
  "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
940
 
941
+ "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
942
+
943
  "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
944
 
945
  "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
946
 
947
+ "typedoc": ["typedoc@0.28.18", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.4", "yaml": "^2.8.2" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-NTWTUOFRQ9+SGKKTuWKUioUkjxNwtS3JDRPVKZAXGHZy2wCA8bdv2iJiyeePn0xkmK+TCCqZFT0X7+2+FLjngA=="],
948
+
949
+ "typedoc-plugin-coverage": ["typedoc-plugin-coverage@4.0.2", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-mfn0e7NCqB8x2PfvhXrtmd7KWlsNf1+B2N9y8gR/jexXBLrXl/0e+b2HdG5HaTXGi7i0t2pyQY2VRmq7gtdEHQ=="],
950
+
951
+ "typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.11.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw=="],
952
+
953
  "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
954
 
955
+ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
956
+
957
  "ui": ["ui@workspace:ui"],
958
 
959
  "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
 
972
 
973
  "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
974
 
975
+ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
976
+
977
  "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
978
 
979
+ "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
980
+
981
  "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
982
 
983
  "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
 
990
 
991
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
992
 
993
+ "xstate": ["xstate@5.30.0", "", {}, "sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw=="],
994
+
995
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
996
+
997
+ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
998
+
999
  "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
1000
 
1001
  "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
 
1020
 
1021
  "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
1022
 
1023
+ "@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.5.4", "", { "dependencies": { "zod": "^4.3.5" } }, "sha512-2pEbhprh8lLGDfUI6mNm9EV104pjb3+aJsXrFaqfgOSre7r6NlgM5HcSbsLjzDAnTikjJhJ3IMal1Rz8WVwiOw=="],
1024
+
1025
+ "@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.5.4", "", { "dependencies": { "zod": "^4.3.5" } }, "sha512-2pEbhprh8lLGDfUI6mNm9EV104pjb3+aJsXrFaqfgOSre7r6NlgM5HcSbsLjzDAnTikjJhJ3IMal1Rz8WVwiOw=="],
1026
+
1027
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
1028
 
1029
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
 
1036
 
1037
  "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
1038
 
1039
+ "globby/unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="],
1040
+
1041
  "i18next-browser-languagedetector/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
1042
 
1043
  "i18next-localstorage-backend/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
koharu-core/Cargo.toml CHANGED
@@ -20,7 +20,7 @@ serde = { workspace = true }
20
  serde_bytes = { workspace = true }
21
  strum = { workspace = true }
22
  tokio = { workspace = true }
23
- ts-rs = { workspace = true }
24
  uuid = { workspace = true }
25
 
26
  [dev-dependencies]
 
20
  serde_bytes = { workspace = true }
21
  strum = { workspace = true }
22
  tokio = { workspace = true }
23
+ utoipa = { workspace = true }
24
  uuid = { workspace = true }
25
 
26
  [dev-dependencies]
koharu-core/src/effect.rs CHANGED
@@ -3,7 +3,7 @@ use serde::{Deserialize, Deserializer, Serialize};
3
  use std::fmt;
4
  use std::str::FromStr;
5
  use strum::IntoEnumIterator;
6
- use ts_rs::TS;
7
 
8
  #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumIter, strum::EnumString)]
9
  #[strum(serialize_all = "lowercase")]
@@ -12,9 +12,8 @@ enum TextShaderEffectFlag {
12
  Bold,
13
  }
14
 
15
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default, TS, JsonSchema)]
16
  #[serde(rename_all = "camelCase")]
17
- #[ts(export)]
18
  pub struct TextShaderEffect {
19
  #[serde(default)]
20
  pub italic: bool,
 
3
  use std::fmt;
4
  use std::str::FromStr;
5
  use strum::IntoEnumIterator;
6
+ use utoipa::ToSchema;
7
 
8
  #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::EnumIter, strum::EnumString)]
9
  #[strum(serialize_all = "lowercase")]
 
12
  Bold,
13
  }
14
 
15
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default, ToSchema, JsonSchema)]
16
  #[serde(rename_all = "camelCase")]
 
17
  pub struct TextShaderEffect {
18
  #[serde(default)]
19
  pub italic: bool,
koharu-core/src/font.rs CHANGED
@@ -1,16 +1,14 @@
1
  use schemars::JsonSchema;
2
  use serde::{Deserialize, Serialize};
3
- use ts_rs::TS;
4
 
5
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS, JsonSchema)]
6
- #[ts(export)]
7
  pub enum TextDirection {
8
  Horizontal,
9
  Vertical,
10
  }
11
 
12
- #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)]
13
- #[ts(export)]
14
  pub struct NamedFontPrediction {
15
  pub index: usize,
16
  pub name: String,
@@ -19,8 +17,7 @@ pub struct NamedFontPrediction {
19
  pub serif: bool,
20
  }
21
 
22
- #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)]
23
- #[ts(export)]
24
  pub struct FontPrediction {
25
  pub top_fonts: Vec<(usize, f32)>,
26
  pub named_fonts: Vec<NamedFontPrediction>,
 
1
  use schemars::JsonSchema;
2
  use serde::{Deserialize, Serialize};
3
+ use utoipa::ToSchema;
4
 
5
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema, JsonSchema)]
 
6
  pub enum TextDirection {
7
  Horizontal,
8
  Vertical,
9
  }
10
 
11
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
 
12
  pub struct NamedFontPrediction {
13
  pub index: usize,
14
  pub name: String,
 
17
  pub serif: bool,
18
  }
19
 
20
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
 
21
  pub struct FontPrediction {
22
  pub top_fonts: Vec<(usize, f32)>,
23
  pub named_fonts: Vec<NamedFontPrediction>,
koharu-core/src/lib.rs CHANGED
@@ -21,7 +21,7 @@ use ::image::GenericImageView;
21
  use schemars::JsonSchema;
22
  use serde::{Deserialize, Serialize};
23
  use tokio::sync::RwLock;
24
- use ts_rs::TS;
25
  use uuid::Uuid;
26
 
27
  fn new_text_block_id() -> String {
@@ -96,9 +96,8 @@ impl TextBlock {
96
  }
97
  }
98
 
99
- #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)]
100
  #[serde(rename_all = "camelCase")]
101
- #[ts(export)]
102
  pub struct TextStrokeStyle {
103
  #[serde(default = "default_true")]
104
  pub enabled: bool,
@@ -126,9 +125,8 @@ const fn default_stroke_color() -> [u8; 4] {
126
  [255, 255, 255, 255]
127
  }
128
 
129
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, TS, JsonSchema)]
130
  #[serde(rename_all = "camelCase")]
131
- #[ts(export)]
132
  pub enum TextAlign {
133
  #[default]
134
  Left,
@@ -136,9 +134,8 @@ pub enum TextAlign {
136
  Right,
137
  }
138
 
139
- #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)]
140
  #[serde(rename_all = "camelCase")]
141
- #[ts(export)]
142
  pub struct TextStyle {
143
  pub font_families: Vec<String>,
144
  pub font_size: Option<f32>,
 
21
  use schemars::JsonSchema;
22
  use serde::{Deserialize, Serialize};
23
  use tokio::sync::RwLock;
24
+ use utoipa::ToSchema;
25
  use uuid::Uuid;
26
 
27
  fn new_text_block_id() -> String {
 
96
  }
97
  }
98
 
99
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
100
  #[serde(rename_all = "camelCase")]
 
101
  pub struct TextStrokeStyle {
102
  #[serde(default = "default_true")]
103
  pub enabled: bool,
 
125
  [255, 255, 255, 255]
126
  }
127
 
128
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema, JsonSchema)]
129
  #[serde(rename_all = "camelCase")]
 
130
  pub enum TextAlign {
131
  #[default]
132
  Left,
 
134
  Right,
135
  }
136
 
137
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
138
  #[serde(rename_all = "camelCase")]
 
139
  pub struct TextStyle {
140
  pub font_families: Vec<String>,
141
  pub font_size: Option<f32>,
koharu-core/src/protocol.rs CHANGED
@@ -1,51 +1,45 @@
1
  use schemars::JsonSchema;
2
  use serde::{Deserialize, Serialize};
3
- use ts_rs::TS;
4
 
5
  use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
6
 
7
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, TS)]
8
  #[serde(rename_all = "camelCase")]
9
- #[ts(export)]
10
  pub struct FontFaceInfo {
11
  pub family_name: String,
12
  pub post_script_name: String,
13
  }
14
 
15
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
16
  #[serde(rename_all = "camelCase")]
17
- #[ts(export)]
18
  pub struct MetaInfo {
19
  pub version: String,
20
  pub ml_device: String,
21
  }
22
 
23
- #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)]
24
  #[serde(rename_all = "camelCase")]
25
- #[ts(export)]
26
  pub struct BootstrapConfig {
27
  pub runtime: BootstrapPathConfig,
28
  pub models: BootstrapPathConfig,
29
  pub http: BootstrapHttpConfig,
30
  }
31
 
32
- #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)]
33
  #[serde(rename_all = "camelCase")]
34
- #[ts(export)]
35
  pub struct BootstrapPathConfig {
36
  pub path: String,
37
  }
38
 
39
- #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)]
40
  #[serde(rename_all = "camelCase")]
41
- #[ts(export)]
42
  pub struct BootstrapHttpConfig {
43
  pub proxy: Option<String>,
44
  }
45
 
46
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
47
  #[serde(rename_all = "camelCase")]
48
- #[ts(export)]
49
  pub struct DocumentSummary {
50
  pub id: String,
51
  pub name: String,
@@ -76,9 +70,8 @@ impl From<&Document> for DocumentSummary {
76
  }
77
  }
78
 
79
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
80
  #[serde(rename_all = "camelCase")]
81
- #[ts(export)]
82
  pub struct TextBlockDetail {
83
  pub id: String,
84
  pub x: f32,
@@ -123,9 +116,8 @@ impl From<&TextBlock> for TextBlockDetail {
123
  }
124
  }
125
 
126
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
127
  #[serde(rename_all = "camelCase")]
128
- #[ts(export)]
129
  pub struct DocumentDetail {
130
  pub id: String,
131
  pub path: String,
@@ -134,29 +126,38 @@ pub struct DocumentDetail {
134
  pub height: u32,
135
  pub revision: u64,
136
  pub text_blocks: Vec<TextBlockDetail>,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
138
 
139
- impl From<&Document> for DocumentDetail {
140
- fn from(document: &Document) -> Self {
141
- Self {
142
- id: document.id.clone(),
143
- path: document.path.to_string_lossy().to_string(),
144
- name: document.name.clone(),
145
- width: document.width,
146
- height: document.height,
147
- revision: document.revision,
148
- text_blocks: document
149
- .text_blocks
150
- .iter()
151
- .map(TextBlockDetail::from)
152
- .collect(),
153
- }
154
- }
155
- }
156
-
157
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
158
  #[serde(rename_all = "camelCase")]
159
- #[ts(export)]
160
  pub struct TextBlockPatch {
161
  pub text: Option<String>,
162
  pub translation: Option<String>,
@@ -167,9 +168,8 @@ pub struct TextBlockPatch {
167
  pub style: Option<TextStyle>,
168
  }
169
 
170
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
171
  #[serde(rename_all = "camelCase")]
172
- #[ts(export)]
173
  pub struct CreateTextBlock {
174
  pub x: f32,
175
  pub y: f32,
@@ -177,49 +177,43 @@ pub struct CreateTextBlock {
177
  pub height: f32,
178
  }
179
 
180
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, TS)]
181
  #[serde(rename_all = "snake_case")]
182
- #[ts(export)]
183
  pub enum ImportMode {
184
  Replace,
185
  Append,
186
  }
187
 
188
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
189
  #[serde(rename_all = "camelCase")]
190
- #[ts(export)]
191
  pub struct ImportResult {
192
  pub total_count: usize,
193
  pub documents: Vec<DocumentSummary>,
194
  }
195
 
196
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, TS)]
197
  #[serde(rename_all = "snake_case")]
198
- #[ts(export)]
199
  pub enum ExportLayer {
200
  Rendered,
201
  Inpainted,
202
  }
203
 
204
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
205
  #[serde(rename_all = "camelCase")]
206
- #[ts(export)]
207
  pub struct ExportResult {
208
  pub count: usize,
209
  }
210
 
211
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
212
  #[serde(rename_all = "camelCase")]
213
- #[ts(export)]
214
  pub struct LlmModelInfo {
215
  pub id: String,
216
  pub languages: Vec<String>,
217
  pub source: String,
218
  }
219
 
220
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, TS)]
221
  #[serde(rename_all = "snake_case")]
222
- #[ts(export)]
223
  pub enum LlmStateStatus {
224
  Empty,
225
  Loading,
@@ -227,9 +221,8 @@ pub enum LlmStateStatus {
227
  Failed,
228
  }
229
 
230
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
231
  #[serde(rename_all = "camelCase")]
232
- #[ts(export)]
233
  pub struct LlmState {
234
  pub status: LlmStateStatus,
235
  pub model_id: Option<String>,
@@ -237,9 +230,8 @@ pub struct LlmState {
237
  pub error: Option<String>,
238
  }
239
 
240
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
241
  #[serde(rename_all = "camelCase")]
242
- #[ts(export)]
243
  pub struct LlmLoadRequest {
244
  pub id: String,
245
  pub api_key: Option<String>,
@@ -249,17 +241,15 @@ pub struct LlmLoadRequest {
249
  pub custom_system_prompt: Option<String>,
250
  }
251
 
252
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
253
  #[serde(rename_all = "camelCase")]
254
- #[ts(export)]
255
  pub struct LlmPingRequest {
256
  pub base_url: String,
257
  pub api_key: Option<String>,
258
  }
259
 
260
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
261
  #[serde(rename_all = "camelCase")]
262
- #[ts(export)]
263
  pub struct LlmPingResponse {
264
  pub ok: bool,
265
  pub models: Vec<String>,
@@ -267,9 +257,8 @@ pub struct LlmPingResponse {
267
  pub error: Option<String>,
268
  }
269
 
270
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, TS)]
271
  #[serde(rename_all = "snake_case")]
272
- #[ts(export)]
273
  pub enum JobStatus {
274
  Running,
275
  Completed,
@@ -277,9 +266,8 @@ pub enum JobStatus {
277
  Failed,
278
  }
279
 
280
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
281
  #[serde(rename_all = "camelCase")]
282
- #[ts(export)]
283
  pub struct JobState {
284
  pub id: String,
285
  pub kind: String,
@@ -293,9 +281,8 @@ pub struct JobState {
293
  pub error: Option<String>,
294
  }
295
 
296
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, TS)]
297
  #[serde(rename_all = "snake_case")]
298
- #[ts(export)]
299
  pub enum TransferStatus {
300
  Started,
301
  Downloading,
@@ -303,9 +290,8 @@ pub enum TransferStatus {
303
  Failed,
304
  }
305
 
306
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
307
  #[serde(rename_all = "camelCase")]
308
- #[ts(export)]
309
  pub struct DownloadState {
310
  pub id: String,
311
  pub filename: String,
@@ -315,9 +301,8 @@ pub struct DownloadState {
315
  pub error: Option<String>,
316
  }
317
 
318
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
319
  #[serde(rename_all = "camelCase")]
320
- #[ts(export)]
321
  pub struct SnapshotEvent {
322
  pub documents: Vec<DocumentSummary>,
323
  pub llm: LlmState,
@@ -325,39 +310,34 @@ pub struct SnapshotEvent {
325
  pub downloads: Vec<DownloadState>,
326
  }
327
 
328
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
329
  #[serde(rename_all = "camelCase")]
330
- #[ts(export)]
331
  pub struct DocumentsChangedEvent {
332
  pub documents: Vec<DocumentSummary>,
333
  }
334
 
335
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
336
  #[serde(rename_all = "camelCase")]
337
- #[ts(export)]
338
  pub struct DocumentChangedEvent {
339
  pub document_id: String,
340
  pub revision: u64,
341
  pub changed: Vec<String>,
342
  }
343
 
344
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
345
  #[serde(rename_all = "camelCase")]
346
- #[ts(export)]
347
  pub struct ApiKeyValue {
348
  pub api_key: String,
349
  }
350
 
351
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
352
  #[serde(rename_all = "camelCase")]
353
- #[ts(export)]
354
  pub struct ApiKeyResponse {
355
  pub api_key: Option<String>,
356
  }
357
 
358
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
359
  #[serde(rename_all = "camelCase")]
360
- #[ts(export)]
361
  pub struct RenderRequest {
362
  pub text_block_id: Option<String>,
363
  pub shader_effect: Option<TextShaderEffect>,
@@ -365,17 +345,15 @@ pub struct RenderRequest {
365
  pub font_family: Option<String>,
366
  }
367
 
368
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
369
  #[serde(rename_all = "camelCase")]
370
- #[ts(export)]
371
  pub struct TranslateRequest {
372
  pub text_block_id: Option<String>,
373
  pub language: Option<String>,
374
  }
375
 
376
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
377
  #[serde(rename_all = "camelCase")]
378
- #[ts(export)]
379
  pub struct PipelineJobRequest {
380
  pub document_id: Option<String>,
381
  pub llm_model_id: Option<String>,
@@ -390,9 +368,8 @@ pub struct PipelineJobRequest {
390
  pub font_family: Option<String>,
391
  }
392
 
393
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
394
  #[serde(rename_all = "camelCase")]
395
- #[ts(export)]
396
  pub struct Region {
397
  pub x: u32,
398
  pub y: u32,
@@ -400,25 +377,22 @@ pub struct Region {
400
  pub height: u32,
401
  }
402
 
403
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
404
  #[serde(rename_all = "camelCase")]
405
- #[ts(export)]
406
  pub struct MaskRegionRequest {
407
  pub data: Vec<u8>,
408
  pub region: Option<Region>,
409
  }
410
 
411
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
412
  #[serde(rename_all = "camelCase")]
413
- #[ts(export)]
414
  pub struct BrushRegionRequest {
415
  pub data: Vec<u8>,
416
  pub region: Region,
417
  }
418
 
419
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
420
  #[serde(rename_all = "camelCase")]
421
- #[ts(export)]
422
  pub struct InpaintRegionRequest {
423
  pub region: Region,
424
  }
 
1
  use schemars::JsonSchema;
2
  use serde::{Deserialize, Serialize};
3
+ use utoipa::ToSchema;
4
 
5
  use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
6
 
7
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema)]
8
  #[serde(rename_all = "camelCase")]
 
9
  pub struct FontFaceInfo {
10
  pub family_name: String,
11
  pub post_script_name: String,
12
  }
13
 
14
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
15
  #[serde(rename_all = "camelCase")]
 
16
  pub struct MetaInfo {
17
  pub version: String,
18
  pub ml_device: String,
19
  }
20
 
21
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
22
  #[serde(rename_all = "camelCase")]
 
23
  pub struct BootstrapConfig {
24
  pub runtime: BootstrapPathConfig,
25
  pub models: BootstrapPathConfig,
26
  pub http: BootstrapHttpConfig,
27
  }
28
 
29
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
30
  #[serde(rename_all = "camelCase")]
 
31
  pub struct BootstrapPathConfig {
32
  pub path: String,
33
  }
34
 
35
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
36
  #[serde(rename_all = "camelCase")]
 
37
  pub struct BootstrapHttpConfig {
38
  pub proxy: Option<String>,
39
  }
40
 
41
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
42
  #[serde(rename_all = "camelCase")]
 
43
  pub struct DocumentSummary {
44
  pub id: String,
45
  pub name: String,
 
70
  }
71
  }
72
 
73
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
74
  #[serde(rename_all = "camelCase")]
 
75
  pub struct TextBlockDetail {
76
  pub id: String,
77
  pub x: f32,
 
116
  }
117
  }
118
 
119
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
120
  #[serde(rename_all = "camelCase")]
 
121
  pub struct DocumentDetail {
122
  pub id: String,
123
  pub path: String,
 
126
  pub height: u32,
127
  pub revision: u64,
128
  pub text_blocks: Vec<TextBlockDetail>,
129
+ #[schema(value_type = Vec<u8>)]
130
+ pub image: serde_bytes::ByteBuf,
131
+ #[serde(skip_serializing_if = "Option::is_none")]
132
+ #[schema(value_type = Option<Vec<u8>>)]
133
+ pub segment: Option<serde_bytes::ByteBuf>,
134
+ #[serde(skip_serializing_if = "Option::is_none")]
135
+ #[schema(value_type = Option<Vec<u8>>)]
136
+ pub inpainted: Option<serde_bytes::ByteBuf>,
137
+ #[serde(skip_serializing_if = "Option::is_none")]
138
+ #[schema(value_type = Option<Vec<u8>>)]
139
+ pub brush_layer: Option<serde_bytes::ByteBuf>,
140
+ #[serde(skip_serializing_if = "Option::is_none")]
141
+ #[schema(value_type = Option<Vec<u8>>)]
142
+ pub rendered: Option<serde_bytes::ByteBuf>,
143
+ }
144
+
145
+
146
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
147
+ #[serde(rename_all = "camelCase")]
148
+ pub struct TextBlockInput {
149
+ pub id: Option<String>,
150
+ pub x: f32,
151
+ pub y: f32,
152
+ pub width: f32,
153
+ pub height: f32,
154
+ pub text: Option<String>,
155
+ pub translation: Option<String>,
156
+ pub style: Option<TextStyle>,
157
  }
158
 
159
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  #[serde(rename_all = "camelCase")]
 
161
  pub struct TextBlockPatch {
162
  pub text: Option<String>,
163
  pub translation: Option<String>,
 
168
  pub style: Option<TextStyle>,
169
  }
170
 
171
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
172
  #[serde(rename_all = "camelCase")]
 
173
  pub struct CreateTextBlock {
174
  pub x: f32,
175
  pub y: f32,
 
177
  pub height: f32,
178
  }
179
 
180
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
181
  #[serde(rename_all = "snake_case")]
 
182
  pub enum ImportMode {
183
  Replace,
184
  Append,
185
  }
186
 
187
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
188
  #[serde(rename_all = "camelCase")]
 
189
  pub struct ImportResult {
190
  pub total_count: usize,
191
  pub documents: Vec<DocumentSummary>,
192
  }
193
 
194
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
195
  #[serde(rename_all = "snake_case")]
 
196
  pub enum ExportLayer {
197
  Rendered,
198
  Inpainted,
199
  }
200
 
201
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
202
  #[serde(rename_all = "camelCase")]
 
203
  pub struct ExportResult {
204
  pub count: usize,
205
  }
206
 
207
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
208
  #[serde(rename_all = "camelCase")]
 
209
  pub struct LlmModelInfo {
210
  pub id: String,
211
  pub languages: Vec<String>,
212
  pub source: String,
213
  }
214
 
215
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
216
  #[serde(rename_all = "snake_case")]
 
217
  pub enum LlmStateStatus {
218
  Empty,
219
  Loading,
 
221
  Failed,
222
  }
223
 
224
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
225
  #[serde(rename_all = "camelCase")]
 
226
  pub struct LlmState {
227
  pub status: LlmStateStatus,
228
  pub model_id: Option<String>,
 
230
  pub error: Option<String>,
231
  }
232
 
233
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
234
  #[serde(rename_all = "camelCase")]
 
235
  pub struct LlmLoadRequest {
236
  pub id: String,
237
  pub api_key: Option<String>,
 
241
  pub custom_system_prompt: Option<String>,
242
  }
243
 
244
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
245
  #[serde(rename_all = "camelCase")]
 
246
  pub struct LlmPingRequest {
247
  pub base_url: String,
248
  pub api_key: Option<String>,
249
  }
250
 
251
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
252
  #[serde(rename_all = "camelCase")]
 
253
  pub struct LlmPingResponse {
254
  pub ok: bool,
255
  pub models: Vec<String>,
 
257
  pub error: Option<String>,
258
  }
259
 
260
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
261
  #[serde(rename_all = "snake_case")]
 
262
  pub enum JobStatus {
263
  Running,
264
  Completed,
 
266
  Failed,
267
  }
268
 
269
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
270
  #[serde(rename_all = "camelCase")]
 
271
  pub struct JobState {
272
  pub id: String,
273
  pub kind: String,
 
281
  pub error: Option<String>,
282
  }
283
 
284
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
285
  #[serde(rename_all = "snake_case")]
 
286
  pub enum TransferStatus {
287
  Started,
288
  Downloading,
 
290
  Failed,
291
  }
292
 
293
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
294
  #[serde(rename_all = "camelCase")]
 
295
  pub struct DownloadState {
296
  pub id: String,
297
  pub filename: String,
 
301
  pub error: Option<String>,
302
  }
303
 
304
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
305
  #[serde(rename_all = "camelCase")]
 
306
  pub struct SnapshotEvent {
307
  pub documents: Vec<DocumentSummary>,
308
  pub llm: LlmState,
 
310
  pub downloads: Vec<DownloadState>,
311
  }
312
 
313
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
314
  #[serde(rename_all = "camelCase")]
 
315
  pub struct DocumentsChangedEvent {
316
  pub documents: Vec<DocumentSummary>,
317
  }
318
 
319
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
320
  #[serde(rename_all = "camelCase")]
 
321
  pub struct DocumentChangedEvent {
322
  pub document_id: String,
323
  pub revision: u64,
324
  pub changed: Vec<String>,
325
  }
326
 
327
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
328
  #[serde(rename_all = "camelCase")]
 
329
  pub struct ApiKeyValue {
330
  pub api_key: String,
331
  }
332
 
333
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
334
  #[serde(rename_all = "camelCase")]
 
335
  pub struct ApiKeyResponse {
336
  pub api_key: Option<String>,
337
  }
338
 
339
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
340
  #[serde(rename_all = "camelCase")]
 
341
  pub struct RenderRequest {
342
  pub text_block_id: Option<String>,
343
  pub shader_effect: Option<TextShaderEffect>,
 
345
  pub font_family: Option<String>,
346
  }
347
 
348
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
349
  #[serde(rename_all = "camelCase")]
 
350
  pub struct TranslateRequest {
351
  pub text_block_id: Option<String>,
352
  pub language: Option<String>,
353
  }
354
 
355
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
356
  #[serde(rename_all = "camelCase")]
 
357
  pub struct PipelineJobRequest {
358
  pub document_id: Option<String>,
359
  pub llm_model_id: Option<String>,
 
368
  pub font_family: Option<String>,
369
  }
370
 
371
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
372
  #[serde(rename_all = "camelCase")]
 
373
  pub struct Region {
374
  pub x: u32,
375
  pub y: u32,
 
377
  pub height: u32,
378
  }
379
 
380
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
381
  #[serde(rename_all = "camelCase")]
 
382
  pub struct MaskRegionRequest {
383
  pub data: Vec<u8>,
384
  pub region: Option<Region>,
385
  }
386
 
387
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
388
  #[serde(rename_all = "camelCase")]
 
389
  pub struct BrushRegionRequest {
390
  pub data: Vec<u8>,
391
  pub region: Region,
392
  }
393
 
394
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
395
  #[serde(rename_all = "camelCase")]
 
396
  pub struct InpaintRegionRequest {
397
  pub region: Region,
398
  }
koharu-rpc/Cargo.toml CHANGED
@@ -17,17 +17,22 @@ koharu-core = { workspace = true }
17
  koharu-psd = { workspace = true }
18
  koharu-runtime = { workspace = true }
19
  anyhow = { workspace = true }
20
- async-stream = { workspace = true }
21
  axum = { workspace = true }
22
  base64 = { workspace = true }
23
- futures = { workspace = true }
24
  image = { workspace = true }
25
  imageproc = { workspace = true }
26
  rmcp = { workspace = true }
27
  rmp-serde = { workspace = true }
28
  rmpv = { workspace = true }
29
  serde = { workspace = true }
 
30
  serde_json = { workspace = true }
31
  tokio = { workspace = true }
32
  tower-http = { workspace = true }
 
 
33
  tracing = { workspace = true }
 
 
 
 
 
17
  koharu-psd = { workspace = true }
18
  koharu-runtime = { workspace = true }
19
  anyhow = { workspace = true }
 
20
  axum = { workspace = true }
21
  base64 = { workspace = true }
 
22
  image = { workspace = true }
23
  imageproc = { workspace = true }
24
  rmcp = { workspace = true }
25
  rmp-serde = { workspace = true }
26
  rmpv = { workspace = true }
27
  serde = { workspace = true }
28
+ serde_bytes = { workspace = true }
29
  serde_json = { workspace = true }
30
  tokio = { workspace = true }
31
  tower-http = { workspace = true }
32
+ utoipa = { workspace = true }
33
+ utoipa-axum = { workspace = true }
34
  tracing = { workspace = true }
35
+
36
+ [[bin]]
37
+ name = "openapi"
38
+ path = "src/bin/openapi.rs"
koharu-rpc/src/api.rs CHANGED
@@ -1,20 +1,15 @@
1
- use std::{convert::Infallible, io::Cursor, time::Duration};
2
 
3
  use anyhow::Context;
4
- use async_stream::stream;
5
  use axum::{
6
- Json, Router,
7
  body::Body,
8
  extract::{DefaultBodyLimit, Multipart, Path, Query, State},
9
  http::{
10
  HeaderValue, StatusCode,
11
  header::{CONTENT_DISPOSITION, CONTENT_TYPE},
12
  },
13
- response::{
14
- IntoResponse, Response,
15
- sse::{Event, KeepAlive, Sse},
16
- },
17
- routing::{delete, get, patch, post, put},
18
  };
19
  use image::ImageFormat;
20
  use koharu_app::{
@@ -22,20 +17,20 @@ use koharu_app::{
22
  state_tx::{self, ChangedField},
23
  };
24
  use koharu_core::{
25
- ApiKeyGetPayload, ApiKeyResponse, ApiKeySetPayload, ApiKeyValue, BootstrapConfig,
26
- CreateTextBlock, Document, DocumentDetail, DocumentSummary, ExportLayer, ExportResult,
27
- FileEntry, FontFaceInfo, IndexPayload, InpaintPartialPayload, InpaintRegion, JobState,
28
- JobStatus, LlmLoadPayload, LlmLoadRequest, LlmModelInfo, LlmPingRequest, LlmPingResponse,
29
- MaskRegionRequest, MetaInfo, OpenDocumentsPayload, PipelineJobRequest, Region, RenderPayload,
30
- RenderRequest, SerializableDynamicImage, TextBlock, TextBlockDetail, TextBlockPatch,
31
- TranslateRequest, UpdateBrushLayerPayload, UpdateInpaintMaskPayload,
32
  };
33
  use koharu_psd::{PsdExportOptions, TextLayerMode};
34
- use serde::Deserialize;
 
 
35
 
36
  use crate::{
37
- events::{ApiEvent, EventHub},
38
  shared::{SharedState, get_resources},
 
39
  };
40
 
41
  const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
@@ -43,7 +38,7 @@ const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
43
  #[derive(Clone)]
44
  pub struct ApiState {
45
  pub resources: SharedState,
46
- pub events: EventHub,
47
  }
48
 
49
  impl ApiState {
@@ -52,84 +47,58 @@ impl ApiState {
52
  }
53
  }
54
 
55
- pub fn router(resources: SharedState, events: EventHub) -> Router {
56
- let state = ApiState { resources, events };
57
-
58
- Router::new()
59
- .route("/config", get(get_config).put(put_config))
60
- .route("/initialize", post(initialize_app))
61
- .route("/meta", get(get_meta))
62
- .route("/fonts", get(get_fonts))
63
- .route("/documents", get(list_documents))
64
- .route("/documents/import", post(import_documents))
65
- .route("/documents/{document_id}", get(get_document))
66
- .route("/documents/{document_id}/thumbnail", get(get_thumbnail))
67
- .route(
68
- "/documents/{document_id}/layers/{layer}",
69
- get(get_document_layer),
70
- )
71
- .route("/documents/{document_id}/detect", post(detect_document))
72
- .route("/documents/{document_id}/ocr", post(ocr_document))
73
- .route("/documents/{document_id}/inpaint", post(inpaint_document))
74
- .route("/documents/{document_id}/render", post(render_document))
75
- .route(
76
- "/documents/{document_id}/translate",
77
- post(translate_document),
78
- )
79
- .route(
80
- "/documents/{document_id}/mask-region",
81
- put(update_mask_region),
82
- )
83
- .route(
84
- "/documents/{document_id}/brush-region",
85
- put(update_brush_region),
86
- )
87
- .route(
88
- "/documents/{document_id}/inpaint-region",
89
- post(inpaint_region),
90
- )
91
- .route(
92
- "/documents/{document_id}/text-blocks",
93
- post(create_text_block),
94
- )
95
- .route(
96
- "/documents/{document_id}/text-blocks/{text_block_id}",
97
- patch(patch_text_block).delete(delete_text_block),
98
- )
99
- .route("/documents/{document_id}/export", get(export_document))
100
- .route(
101
- "/documents/{document_id}/export/psd",
102
- get(export_document_psd),
103
- )
104
- .route("/llm/models", get(list_llm_models))
105
- .route("/llm/state", get(get_llm_state))
106
- .route("/llm/load", post(load_llm))
107
- .route("/llm/offload", post(offload_llm))
108
- .route("/llm/ping", post(ping_llm))
109
- .route(
110
- "/providers/{provider}/api-key",
111
- get(get_api_key).put(set_api_key),
112
- )
113
- .route("/jobs/pipeline", post(start_pipeline_job))
114
- .route("/jobs/{job_id}", delete(cancel_pipeline_job))
115
- .route("/exports", post(export_all))
116
- .route("/events", get(events_stream))
117
  .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
118
  .with_state(state)
119
  }
120
 
121
  type ApiResult<T> = Result<T, ApiError>;
122
 
123
- #[derive(Debug)]
124
  pub struct ApiError {
125
- status: StatusCode,
126
- message: String,
127
  }
128
 
129
  impl ApiError {
130
  fn new(status: StatusCode, message: impl Into<String>) -> Self {
131
  Self {
132
- status,
133
  message: message.into(),
134
  }
135
  }
@@ -168,35 +137,75 @@ impl From<anyhow::Error> for ApiError {
168
 
169
  impl IntoResponse for ApiError {
170
  fn into_response(self) -> Response {
171
- (self.status, self.message).into_response()
 
 
172
  }
173
  }
174
 
175
- #[derive(Debug, Deserialize)]
 
 
 
 
 
 
 
176
  #[serde(rename_all = "camelCase")]
177
  struct ImportQuery {
178
  mode: Option<koharu_core::ImportMode>,
179
  }
180
 
181
- #[derive(Debug, Deserialize)]
 
 
 
 
 
 
 
182
  #[serde(rename_all = "camelCase")]
183
- struct LayerQuery {
184
  layer: Option<ExportLayer>,
185
  }
186
 
187
- #[derive(Debug, Deserialize)]
188
  #[serde(rename_all = "camelCase")]
189
- struct LlmModelsQuery {
190
- language: Option<String>,
191
- openai_compatible_base_url: Option<String>,
192
  }
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  async fn get_config(State(state): State<ApiState>) -> ApiResult<Json<BootstrapConfig>> {
195
  let config = state.resources.get_config().map_err(ApiError::internal)?;
196
  Ok(Json(config))
197
  }
198
 
199
- async fn put_config(
 
 
 
 
 
 
 
 
 
 
 
200
  State(state): State<ApiState>,
201
  Json(config): Json<BootstrapConfig>,
202
  ) -> ApiResult<Json<BootstrapConfig>> {
@@ -204,7 +213,18 @@ async fn put_config(
204
  Ok(Json(saved))
205
  }
206
 
207
- async fn initialize_app(State(state): State<ApiState>) -> ApiResult<StatusCode> {
 
 
 
 
 
 
 
 
 
 
 
208
  match state.resources.initialize().await {
209
  Ok(()) => Ok(StatusCode::NO_CONTENT),
210
  Err(error) => {
@@ -218,6 +238,16 @@ async fn initialize_app(State(state): State<ApiState>) -> ApiResult<StatusCode>
218
  }
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
221
  async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
222
  let resources = state.resources()?;
223
  let device = operations::device(resources.clone()).await?;
@@ -227,7 +257,17 @@ async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
227
  }))
228
  }
229
 
230
- async fn get_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFaceInfo>>> {
 
 
 
 
 
 
 
 
 
 
231
  let resources = state.resources()?;
232
  let fonts = operations::list_font_families(resources)
233
  .await
@@ -235,6 +275,20 @@ async fn get_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFace
235
  Ok(Json(fonts))
236
  }
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<DocumentSummary>>> {
239
  let resources = state.resources()?;
240
  let guard = resources.state.read().await;
@@ -242,6 +296,18 @@ async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<Doc
242
  Ok(Json(documents))
243
  }
244
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  async fn get_document(
246
  State(state): State<ApiState>,
247
  Path(document_id): Path<String>,
@@ -253,13 +319,58 @@ async fn get_document(
253
  .iter()
254
  .find(|d| d.id == document_id)
255
  .ok_or_else(|| ApiError::not_found("Document not found"))?;
256
- Ok(Json(DocumentDetail::from(doc)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
 
259
- async fn get_thumbnail(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  State(state): State<ApiState>,
261
  Path(document_id): Path<String>,
 
262
  ) -> ApiResult<Response> {
 
263
  let resources = state.resources()?;
264
  let guard = resources.state.read().await;
265
  let doc = guard
@@ -268,29 +379,25 @@ async fn get_thumbnail(
268
  .find(|d| d.id == document_id)
269
  .ok_or_else(|| ApiError::not_found("Document not found"))?;
270
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
271
- let thumbnail = source.thumbnail(200, 200);
272
  let bytes = encode_webp(&thumbnail.into())?;
273
  drop(guard);
274
  Ok(binary_response(bytes, "image/webp", None))
275
  }
276
 
277
- async fn get_document_layer(
278
- State(state): State<ApiState>,
279
- Path((document_id, layer)): Path<(String, String)>,
280
- ) -> ApiResult<Response> {
281
- let resources = state.resources()?;
282
- let guard = resources.state.read().await;
283
- let doc = guard
284
- .documents
285
- .iter()
286
- .find(|d| d.id == document_id)
287
- .ok_or_else(|| ApiError::not_found("Document not found"))?;
288
- let image = document_layer(doc, &layer)?;
289
- let bytes = encode_webp(image)?;
290
- drop(guard);
291
- Ok(binary_response(bytes, "image/webp", None))
292
- }
293
-
294
  async fn import_documents(
295
  State(state): State<ApiState>,
296
  Query(query): Query<ImportQuery>,
@@ -312,7 +419,7 @@ async fn import_documents(
312
  .bytes()
313
  .await
314
  .map_err(|error| ApiError::bad_request(error.to_string()))?;
315
- files.push(FileEntry {
316
  name: filename,
317
  data: data.to_vec(),
318
  });
@@ -322,7 +429,7 @@ async fn import_documents(
322
  return Err(ApiError::bad_request("No files uploaded"));
323
  }
324
 
325
- let payload = OpenDocumentsPayload { files };
326
  match query.mode.unwrap_or(koharu_core::ImportMode::Replace) {
327
  koharu_core::ImportMode::Replace => {
328
  operations::open_documents(resources.clone(), payload).await?;
@@ -344,36 +451,89 @@ async fn import_documents(
344
  }))
345
  }
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  async fn detect_document(
348
  State(state): State<ApiState>,
349
  Path(document_id): Path<String>,
350
  ) -> ApiResult<StatusCode> {
351
  let resources = state.resources()?;
352
  let (index, _) = find_document(&resources, &document_id).await?;
353
- operations::detect(resources, IndexPayload { index }).await?;
354
  Ok(StatusCode::NO_CONTENT)
355
  }
356
 
357
- async fn ocr_document(
 
 
 
 
 
 
 
 
 
 
 
 
358
  State(state): State<ApiState>,
359
  Path(document_id): Path<String>,
360
  ) -> ApiResult<StatusCode> {
361
  let resources = state.resources()?;
362
  let (index, _) = find_document(&resources, &document_id).await?;
363
- operations::ocr(resources, IndexPayload { index }).await?;
364
  Ok(StatusCode::NO_CONTENT)
365
  }
366
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  async fn inpaint_document(
368
  State(state): State<ApiState>,
369
  Path(document_id): Path<String>,
370
  ) -> ApiResult<StatusCode> {
371
  let resources = state.resources()?;
372
  let (index, _) = find_document(&resources, &document_id).await?;
373
- operations::inpaint(resources, IndexPayload { index }).await?;
374
  Ok(StatusCode::NO_CONTENT)
375
  }
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  async fn render_document(
378
  State(state): State<ApiState>,
379
  Path(document_id): Path<String>,
@@ -389,7 +549,7 @@ async fn render_document(
389
 
390
  operations::render(
391
  resources,
392
- RenderPayload {
393
  index,
394
  text_block_index,
395
  shader_effect: request.shader_effect,
@@ -402,6 +562,19 @@ async fn render_document(
402
  Ok(StatusCode::NO_CONTENT)
403
  }
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  async fn translate_document(
406
  State(state): State<ApiState>,
407
  Path(document_id): Path<String>,
@@ -428,7 +601,24 @@ async fn translate_document(
428
  Ok(StatusCode::NO_CONTENT)
429
  }
430
 
431
- async fn update_mask_region(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  State(state): State<ApiState>,
433
  Path(document_id): Path<String>,
434
  Json(request): Json<MaskRegionRequest>,
@@ -437,7 +627,7 @@ async fn update_mask_region(
437
  let (index, _) = find_document(&resources, &document_id).await?;
438
  operations::update_inpaint_mask(
439
  resources,
440
- UpdateInpaintMaskPayload {
441
  index,
442
  mask: request.data,
443
  region: request.region.map(to_inpaint_region),
@@ -447,7 +637,19 @@ async fn update_mask_region(
447
  Ok(StatusCode::NO_CONTENT)
448
  }
449
 
450
- async fn update_brush_region(
 
 
 
 
 
 
 
 
 
 
 
 
451
  State(state): State<ApiState>,
452
  Path(document_id): Path<String>,
453
  Json(request): Json<koharu_core::BrushRegionRequest>,
@@ -456,7 +658,7 @@ async fn update_brush_region(
456
  let (index, _) = find_document(&resources, &document_id).await?;
457
  operations::update_brush_layer(
458
  resources,
459
- UpdateBrushLayerPayload {
460
  index,
461
  patch: request.data,
462
  region: to_inpaint_region(request.region),
@@ -466,6 +668,18 @@ async fn update_brush_region(
466
  Ok(StatusCode::NO_CONTENT)
467
  }
468
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  async fn inpaint_region(
470
  State(state): State<ApiState>,
471
  Path(document_id): Path<String>,
@@ -475,7 +689,7 @@ async fn inpaint_region(
475
  let (index, _) = find_document(&resources, &document_id).await?;
476
  operations::inpaint_partial(
477
  resources,
478
- InpaintPartialPayload {
479
  index,
480
  region: to_inpaint_region(request.region),
481
  },
@@ -484,6 +698,23 @@ async fn inpaint_region(
484
  Ok(StatusCode::NO_CONTENT)
485
  }
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  async fn create_text_block(
488
  State(state): State<ApiState>,
489
  Path(document_id): Path<String>,
@@ -519,6 +750,131 @@ async fn create_text_block(
519
  Ok(Json(detail))
520
  }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  async fn patch_text_block(
523
  State(state): State<ApiState>,
524
  Path((document_id, text_block_id)): Path<(String, String)>,
@@ -546,6 +902,21 @@ async fn patch_text_block(
546
  Ok(Json(detail))
547
  }
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  async fn delete_text_block(
550
  State(state): State<ApiState>,
551
  Path((document_id, text_block_id)): Path<(String, String)>,
@@ -572,6 +943,21 @@ async fn delete_text_block(
572
  Ok(StatusCode::NO_CONTENT)
573
  }
574
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  async fn list_llm_models(
576
  State(state): State<ApiState>,
577
  Query(query): Query<LlmModelsQuery>,
@@ -595,11 +981,33 @@ async fn list_llm_models(
595
  Ok(Json(models))
596
  }
597
 
598
- async fn get_llm_state(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
 
 
 
 
 
 
 
 
 
 
599
  let resources = state.resources()?;
600
  Ok(Json(resources.llm.snapshot().await))
601
  }
602
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  async fn load_llm(
604
  State(state): State<ApiState>,
605
  Json(request): Json<LlmLoadRequest>,
@@ -607,7 +1015,7 @@ async fn load_llm(
607
  let resources = state.resources()?;
608
  operations::llm_load(
609
  resources.clone(),
610
- LlmLoadPayload {
611
  id: request.id,
612
  api_key: request.api_key,
613
  base_url: request.base_url,
@@ -620,13 +1028,33 @@ async fn load_llm(
620
  Ok(Json(resources.llm.snapshot().await))
621
  }
622
 
623
- async fn offload_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
 
 
 
 
 
 
 
 
 
 
624
  let resources = state.resources()?;
625
  operations::llm_offload(resources.clone()).await?;
626
  Ok(Json(resources.llm.snapshot().await))
627
  }
628
 
629
- async fn ping_llm(
 
 
 
 
 
 
 
 
 
 
630
  State(state): State<ApiState>,
631
  Json(request): Json<LlmPingRequest>,
632
  ) -> ApiResult<Json<LlmPingResponse>> {
@@ -652,17 +1080,45 @@ async fn ping_llm(
652
  }
653
  }
654
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  async fn get_api_key(
656
  State(state): State<ApiState>,
657
  Path(provider): Path<String>,
658
  ) -> ApiResult<Json<ApiKeyResponse>> {
659
  let resources = state.resources()?;
660
- let result = operations::get_api_key(resources, ApiKeyGetPayload { provider }).await?;
 
661
  Ok(Json(ApiKeyResponse {
662
  api_key: result.api_key,
663
  }))
664
  }
665
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  async fn set_api_key(
667
  State(state): State<ApiState>,
668
  Path(provider): Path<String>,
@@ -671,7 +1127,7 @@ async fn set_api_key(
671
  let resources = state.resources()?;
672
  operations::set_api_key(
673
  resources,
674
- ApiKeySetPayload {
675
  provider,
676
  api_key: request.api_key,
677
  },
@@ -680,7 +1136,22 @@ async fn set_api_key(
680
  Ok(StatusCode::NO_CONTENT)
681
  }
682
 
683
- async fn start_pipeline_job(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  State(state): State<ApiState>,
685
  Json(request): Json<PipelineJobRequest>,
686
  ) -> ApiResult<Json<JobState>> {
@@ -725,12 +1196,60 @@ async fn start_pipeline_job(
725
  overall_percent: 0,
726
  error: None,
727
  };
728
- state.events.publish_job(job.clone()).await;
729
 
730
  Ok(Json(job))
731
  }
732
 
733
- async fn cancel_pipeline_job(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  State(state): State<ApiState>,
735
  Path(job_id): Path<String>,
736
  ) -> ApiResult<StatusCode> {
@@ -750,13 +1269,61 @@ async fn cancel_pipeline_job(
750
  Ok(StatusCode::NO_CONTENT)
751
  }
752
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  async fn export_document(
754
  State(state): State<ApiState>,
755
- Path(document_id): Path<String>,
756
- Query(query): Query<LayerQuery>,
757
  ) -> ApiResult<Response> {
758
  let resources = state.resources()?;
759
  let (_, document) = find_document(&resources, &document_id).await?;
 
 
 
 
 
 
 
 
 
 
 
760
  let layer = query.layer.unwrap_or(ExportLayer::Rendered);
761
  let (image, filename) = export_target(&document, layer)?;
762
  let ext = document
@@ -770,83 +1337,29 @@ async fn export_document(
770
  Ok(binary_response(data, content_type, Some(filename)))
771
  }
772
 
773
- async fn export_document_psd(
 
 
 
 
 
 
 
 
 
 
 
774
  State(state): State<ApiState>,
775
- Path(document_id): Path<String>,
776
- ) -> ApiResult<Response> {
777
- let resources = state.resources()?;
778
- let (_, document) = find_document(&resources, &document_id).await?;
779
- let data = koharu_psd::export_document(&document, &app_psd_export_options())
780
- .map_err(|error| ApiError::bad_request(error.to_string()))?;
781
- Ok(binary_response(
782
- data,
783
- "image/vnd.adobe.photoshop",
784
- Some(psd_export_filename(&document)),
785
- ))
786
- }
787
-
788
- async fn export_all(
789
- State(state): State<ApiState>,
790
- Query(query): Query<LayerQuery>,
791
  ) -> ApiResult<Json<ExportResult>> {
792
  let resources = state.resources()?;
793
- let count = match query.layer.unwrap_or(ExportLayer::Rendered) {
794
  ExportLayer::Rendered => operations::export_all_rendered(resources).await?,
795
  ExportLayer::Inpainted => operations::export_all_inpainted(resources).await?,
796
  };
797
  Ok(Json(ExportResult { count }))
798
  }
799
 
800
- async fn events_stream(
801
- State(state): State<ApiState>,
802
- ) -> ApiResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
803
- let events = state.events.clone();
804
- let snapshot = events.snapshot().await?;
805
- let mut rx = events.subscribe();
806
-
807
- let stream = stream! {
808
- yield Ok(sse_event("snapshot", &snapshot));
809
- loop {
810
- match rx.recv().await {
811
- Ok(event) => {
812
- if let Some(event) = api_event_to_sse(event) {
813
- yield Ok(event);
814
- }
815
- }
816
- Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
817
- tracing::warn!("SSE client lagged behind by {n} events, re-sending snapshot");
818
- match events.snapshot().await {
819
- Ok(snap) => yield Ok(sse_event("snapshot", &snap)),
820
- Err(e) => tracing::warn!("Failed to build resync snapshot: {e}"),
821
- }
822
- }
823
- Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
824
- }
825
- }
826
- };
827
-
828
- Ok(Sse::new(stream).keep_alive(
829
- KeepAlive::new()
830
- .interval(Duration::from_secs(15))
831
- .text("keep-alive"),
832
- ))
833
- }
834
-
835
- fn api_event_to_sse(event: ApiEvent) -> Option<Event> {
836
- match event {
837
- ApiEvent::DocumentsChanged(payload) => Some(sse_event("documents.changed", &payload)),
838
- ApiEvent::DocumentChanged(payload) => Some(sse_event("document.changed", &payload)),
839
- ApiEvent::JobChanged(payload) => Some(sse_event("job.changed", &payload)),
840
- ApiEvent::DownloadChanged(payload) => Some(sse_event("download.changed", &payload)),
841
- ApiEvent::LlmChanged(payload) => Some(sse_event("llm.changed", &payload)),
842
- }
843
- }
844
-
845
- fn sse_event<T: serde::Serialize>(name: &str, payload: &T) -> Event {
846
- let data = serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string());
847
- Event::default().event(name).data(data)
848
- }
849
-
850
  async fn find_document(
851
  resources: &AppResources,
852
  document_id: &str,
@@ -868,37 +1381,11 @@ fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult<
868
  .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))
869
  }
870
 
871
- fn document_layer<'a>(
872
- document: &'a Document,
873
- layer: &str,
874
- ) -> ApiResult<&'a SerializableDynamicImage> {
875
- match layer {
876
- "original" => Ok(&document.image),
877
- "segment" => document
878
- .segment
879
- .as_ref()
880
- .ok_or_else(|| ApiError::not_found("No segment layer available")),
881
- "inpainted" => document
882
- .inpainted
883
- .as_ref()
884
- .ok_or_else(|| ApiError::not_found("No inpainted layer available")),
885
- "rendered" => document
886
- .rendered
887
- .as_ref()
888
- .ok_or_else(|| ApiError::not_found("No rendered layer available")),
889
- "brush" => document
890
- .brush_layer
891
- .as_ref()
892
- .ok_or_else(|| ApiError::not_found("No brush layer available")),
893
- other => Err(ApiError::bad_request(format!("Unknown layer: {other}"))),
894
- }
895
- }
896
-
897
- fn encode_webp(image: &SerializableDynamicImage) -> ApiResult<Vec<u8>> {
898
  encode_image(image, "webp")
899
  }
900
 
901
- fn encode_image(image: &SerializableDynamicImage, ext: &str) -> ApiResult<Vec<u8>> {
902
  let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg);
903
  let mut cursor = Cursor::new(Vec::new());
904
  image
@@ -934,7 +1421,7 @@ fn mime_from_ext(ext: &str) -> &'static str {
934
  fn export_target(
935
  document: &Document,
936
  layer: ExportLayer,
937
- ) -> ApiResult<(&SerializableDynamicImage, String)> {
938
  let ext = document
939
  .path
940
  .extension()
 
1
+ use std::io::Cursor;
2
 
3
  use anyhow::Context;
 
4
  use axum::{
5
+ Json,
6
  body::Body,
7
  extract::{DefaultBodyLimit, Multipart, Path, Query, State},
8
  http::{
9
  HeaderValue, StatusCode,
10
  header::{CONTENT_DISPOSITION, CONTENT_TYPE},
11
  },
12
+ response::{IntoResponse, Response},
 
 
 
 
13
  };
14
  use image::ImageFormat;
15
  use koharu_app::{
 
17
  state_tx::{self, ChangedField},
18
  };
19
  use koharu_core::{
20
+ ApiKeyResponse, ApiKeyValue, BootstrapConfig, CreateTextBlock, Document, DocumentDetail,
21
+ DocumentSummary, DownloadState, ExportLayer, ExportResult, FontFaceInfo, InpaintRegion,
22
+ JobState, JobStatus, LlmLoadRequest, LlmModelInfo, LlmPingRequest, LlmPingResponse,
23
+ MaskRegionRequest, MetaInfo, PipelineJobRequest, Region, RenderRequest, TextBlock,
24
+ TextBlockDetail, TextBlockInput, TextBlockPatch, TranslateRequest,
 
 
25
  };
26
  use koharu_psd::{PsdExportOptions, TextLayerMode};
27
+ use serde::{Deserialize, Serialize};
28
+ use utoipa::IntoParams;
29
+ use utoipa_axum::{router::OpenApiRouter, routes};
30
 
31
  use crate::{
 
32
  shared::{SharedState, get_resources},
33
+ tracker::Tracker,
34
  };
35
 
36
  const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
 
38
  #[derive(Clone)]
39
  pub struct ApiState {
40
  pub resources: SharedState,
41
+ pub tracker: Tracker,
42
  }
43
 
44
  impl ApiState {
 
47
  }
48
  }
49
 
50
+ pub fn api() -> (axum::Router<ApiState>, utoipa::openapi::OpenApi) {
51
+ OpenApiRouter::default()
52
+ .routes(routes!(list_documents, import_documents))
53
+ .routes(routes!(get_document))
54
+ .routes(routes!(get_document_thumbnail))
55
+ .routes(routes!(detect_document))
56
+ .routes(routes!(recognize_document))
57
+ .routes(routes!(inpaint_document))
58
+ .routes(routes!(render_document))
59
+ .routes(routes!(translate_document))
60
+ .routes(routes!(update_mask))
61
+ .routes(routes!(update_brush_layer))
62
+ .routes(routes!(inpaint_region))
63
+ .routes(routes!(create_text_block, put_text_blocks))
64
+ .routes(routes!(patch_text_block, delete_text_block))
65
+ .routes(routes!(export_document))
66
+ .routes(routes!(batch_export))
67
+ .routes(routes!(get_llm, load_llm, unload_llm))
68
+ .routes(routes!(list_llm_models))
69
+ .routes(routes!(check_llm_health))
70
+ .routes(routes!(get_api_key, set_api_key))
71
+ .routes(routes!(start_pipeline))
72
+ .routes(routes!(list_jobs))
73
+ .routes(routes!(get_job, cancel_job))
74
+ .routes(routes!(list_downloads))
75
+ .routes(routes!(get_meta))
76
+ .routes(routes!(list_fonts))
77
+ .routes(routes!(get_config, update_config))
78
+ .routes(routes!(initialize))
79
+ .split_for_parts()
80
+ }
81
+
82
+ pub fn router(resources: SharedState, tracker: Tracker) -> axum::Router {
83
+ let state = ApiState { resources, tracker };
84
+ let (router, _) = api();
85
+ router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  .layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
87
  .with_state(state)
88
  }
89
 
90
  type ApiResult<T> = Result<T, ApiError>;
91
 
92
+ #[derive(Debug, Serialize, utoipa::ToSchema)]
93
  pub struct ApiError {
94
+ pub status: u16,
95
+ pub message: String,
96
  }
97
 
98
  impl ApiError {
99
  fn new(status: StatusCode, message: impl Into<String>) -> Self {
100
  Self {
101
+ status: status.as_u16(),
102
  message: message.into(),
103
  }
104
  }
 
137
 
138
  impl IntoResponse for ApiError {
139
  fn into_response(self) -> Response {
140
+ let status =
141
+ StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
142
+ (status, Json(self)).into_response()
143
  }
144
  }
145
 
146
+ #[derive(Debug, utoipa::ToSchema)]
147
+ #[allow(dead_code)]
148
+ struct MultipartUpload {
149
+ #[schema(value_type = Vec<String>, format = Binary)]
150
+ files: Vec<Vec<u8>>,
151
+ }
152
+
153
+ #[derive(Debug, Deserialize, IntoParams)]
154
  #[serde(rename_all = "camelCase")]
155
  struct ImportQuery {
156
  mode: Option<koharu_core::ImportMode>,
157
  }
158
 
159
+ #[derive(Debug, Deserialize, IntoParams)]
160
+ #[serde(rename_all = "camelCase")]
161
+ struct LlmModelsQuery {
162
+ language: Option<String>,
163
+ openai_compatible_base_url: Option<String>,
164
+ }
165
+
166
+ #[derive(Debug, Deserialize, IntoParams)]
167
  #[serde(rename_all = "camelCase")]
168
+ struct ExportQuery {
169
  layer: Option<ExportLayer>,
170
  }
171
 
172
+ #[derive(Debug, Deserialize, utoipa::ToSchema)]
173
  #[serde(rename_all = "camelCase")]
174
+ struct ExportBatchRequest {
175
+ layer: Option<ExportLayer>,
 
176
  }
177
 
178
+ // ---------------------------------------------------------------------------
179
+ // System
180
+ // ---------------------------------------------------------------------------
181
+
182
+ #[utoipa::path(
183
+ get,
184
+ path = "/config",
185
+ operation_id = "getConfig",
186
+ tag = "system",
187
+ responses(
188
+ (status = 200, body = BootstrapConfig),
189
+ (status = 503, body = ApiError),
190
+ ),
191
+ )]
192
  async fn get_config(State(state): State<ApiState>) -> ApiResult<Json<BootstrapConfig>> {
193
  let config = state.resources.get_config().map_err(ApiError::internal)?;
194
  Ok(Json(config))
195
  }
196
 
197
+ #[utoipa::path(
198
+ put,
199
+ path = "/config",
200
+ operation_id = "updateConfig",
201
+ tag = "system",
202
+ request_body = BootstrapConfig,
203
+ responses(
204
+ (status = 200, body = BootstrapConfig),
205
+ (status = 400, body = ApiError),
206
+ ),
207
+ )]
208
+ async fn update_config(
209
  State(state): State<ApiState>,
210
  Json(config): Json<BootstrapConfig>,
211
  ) -> ApiResult<Json<BootstrapConfig>> {
 
213
  Ok(Json(saved))
214
  }
215
 
216
+ #[utoipa::path(
217
+ post,
218
+ path = "/initialize",
219
+ operation_id = "initialize",
220
+ tag = "system",
221
+ responses(
222
+ (status = 204),
223
+ (status = 409, body = ApiError),
224
+ (status = 500, body = ApiError),
225
+ ),
226
+ )]
227
+ async fn initialize(State(state): State<ApiState>) -> ApiResult<StatusCode> {
228
  match state.resources.initialize().await {
229
  Ok(()) => Ok(StatusCode::NO_CONTENT),
230
  Err(error) => {
 
238
  }
239
  }
240
 
241
+ #[utoipa::path(
242
+ get,
243
+ path = "/meta",
244
+ operation_id = "getMeta",
245
+ tag = "system",
246
+ responses(
247
+ (status = 200, body = MetaInfo),
248
+ (status = 503, body = ApiError),
249
+ ),
250
+ )]
251
  async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
252
  let resources = state.resources()?;
253
  let device = operations::device(resources.clone()).await?;
 
257
  }))
258
  }
259
 
260
+ #[utoipa::path(
261
+ get,
262
+ path = "/fonts",
263
+ operation_id = "listFonts",
264
+ tag = "system",
265
+ responses(
266
+ (status = 200, body = Vec<FontFaceInfo>),
267
+ (status = 503, body = ApiError),
268
+ ),
269
+ )]
270
+ async fn list_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFaceInfo>>> {
271
  let resources = state.resources()?;
272
  let fonts = operations::list_font_families(resources)
273
  .await
 
275
  Ok(Json(fonts))
276
  }
277
 
278
+ // ---------------------------------------------------------------------------
279
+ // Documents
280
+ // ---------------------------------------------------------------------------
281
+
282
+ #[utoipa::path(
283
+ get,
284
+ path = "/documents",
285
+ operation_id = "listDocuments",
286
+ tag = "documents",
287
+ responses(
288
+ (status = 200, body = Vec<DocumentSummary>),
289
+ (status = 503, body = ApiError),
290
+ ),
291
+ )]
292
  async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<DocumentSummary>>> {
293
  let resources = state.resources()?;
294
  let guard = resources.state.read().await;
 
296
  Ok(Json(documents))
297
  }
298
 
299
+ #[utoipa::path(
300
+ get,
301
+ path = "/documents/{document_id}",
302
+ operation_id = "getDocument",
303
+ tag = "documents",
304
+ params(("document_id" = String, Path,)),
305
+ responses(
306
+ (status = 200, body = DocumentDetail),
307
+ (status = 404, body = ApiError),
308
+ (status = 503, body = ApiError),
309
+ ),
310
+ )]
311
  async fn get_document(
312
  State(state): State<ApiState>,
313
  Path(document_id): Path<String>,
 
319
  .iter()
320
  .find(|d| d.id == document_id)
321
  .ok_or_else(|| ApiError::not_found("Document not found"))?;
322
+
323
+ let image = serde_bytes::ByteBuf::from(encode_webp(&doc.image)?);
324
+ let segment = doc.segment.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
325
+ let inpainted = doc.inpainted.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
326
+ let brush_layer = doc.brush_layer.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
327
+ let rendered = doc.rendered.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
328
+
329
+ let text_blocks = doc.text_blocks.iter().map(koharu_core::TextBlockDetail::from).collect();
330
+
331
+ let detail = DocumentDetail {
332
+ id: doc.id.clone(),
333
+ path: doc.path.to_string_lossy().to_string(),
334
+ name: doc.name.clone(),
335
+ width: doc.width,
336
+ height: doc.height,
337
+ revision: doc.revision,
338
+ text_blocks,
339
+ image,
340
+ segment,
341
+ inpainted,
342
+ brush_layer,
343
+ rendered,
344
+ };
345
+
346
+ drop(guard);
347
+ Ok(Json(detail))
348
  }
349
 
350
+ #[derive(Debug, Deserialize, IntoParams)]
351
+ #[serde(rename_all = "camelCase")]
352
+ struct ThumbnailQuery {
353
+ size: Option<u32>,
354
+ }
355
+
356
+ #[utoipa::path(
357
+ get,
358
+ path = "/documents/{document_id}/thumbnail",
359
+ operation_id = "getDocumentThumbnail",
360
+ tag = "documents",
361
+ params(("document_id" = String, Path,), ThumbnailQuery),
362
+ responses(
363
+ (status = 200, content_type = "image/webp", body = inline(String)),
364
+ (status = 404, body = ApiError),
365
+ (status = 503, body = ApiError),
366
+ ),
367
+ )]
368
+ async fn get_document_thumbnail(
369
  State(state): State<ApiState>,
370
  Path(document_id): Path<String>,
371
+ Query(query): Query<ThumbnailQuery>,
372
  ) -> ApiResult<Response> {
373
+ let size = query.size.unwrap_or(200).min(800);
374
  let resources = state.resources()?;
375
  let guard = resources.state.read().await;
376
  let doc = guard
 
379
  .find(|d| d.id == document_id)
380
  .ok_or_else(|| ApiError::not_found("Document not found"))?;
381
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
382
+ let thumbnail = source.thumbnail(size, size);
383
  let bytes = encode_webp(&thumbnail.into())?;
384
  drop(guard);
385
  Ok(binary_response(bytes, "image/webp", None))
386
  }
387
 
388
+ #[utoipa::path(
389
+ post,
390
+ path = "/documents",
391
+ operation_id = "importDocuments",
392
+ tag = "documents",
393
+ params(ImportQuery),
394
+ request_body(content_type = "multipart/form-data", content = inline(MultipartUpload)),
395
+ responses(
396
+ (status = 200, body = koharu_core::ImportResult),
397
+ (status = 400, body = ApiError),
398
+ (status = 503, body = ApiError),
399
+ ),
400
+ )]
 
 
 
 
401
  async fn import_documents(
402
  State(state): State<ApiState>,
403
  Query(query): Query<ImportQuery>,
 
419
  .bytes()
420
  .await
421
  .map_err(|error| ApiError::bad_request(error.to_string()))?;
422
+ files.push(koharu_core::FileEntry {
423
  name: filename,
424
  data: data.to_vec(),
425
  });
 
429
  return Err(ApiError::bad_request("No files uploaded"));
430
  }
431
 
432
+ let payload = koharu_core::OpenDocumentsPayload { files };
433
  match query.mode.unwrap_or(koharu_core::ImportMode::Replace) {
434
  koharu_core::ImportMode::Replace => {
435
  operations::open_documents(resources.clone(), payload).await?;
 
451
  }))
452
  }
453
 
454
+ // ---------------------------------------------------------------------------
455
+ // Processing
456
+ // ---------------------------------------------------------------------------
457
+
458
+ #[utoipa::path(
459
+ post,
460
+ path = "/documents/{document_id}/detect",
461
+ operation_id = "detectDocument",
462
+ tag = "processing",
463
+ params(("document_id" = String, Path,)),
464
+ responses(
465
+ (status = 204),
466
+ (status = 404, body = ApiError),
467
+ (status = 503, body = ApiError),
468
+ ),
469
+ )]
470
  async fn detect_document(
471
  State(state): State<ApiState>,
472
  Path(document_id): Path<String>,
473
  ) -> ApiResult<StatusCode> {
474
  let resources = state.resources()?;
475
  let (index, _) = find_document(&resources, &document_id).await?;
476
+ operations::detect(resources, koharu_core::IndexPayload { index }).await?;
477
  Ok(StatusCode::NO_CONTENT)
478
  }
479
 
480
+ #[utoipa::path(
481
+ post,
482
+ path = "/documents/{document_id}/recognize",
483
+ operation_id = "recognizeDocument",
484
+ tag = "processing",
485
+ params(("document_id" = String, Path,)),
486
+ responses(
487
+ (status = 204),
488
+ (status = 404, body = ApiError),
489
+ (status = 503, body = ApiError),
490
+ ),
491
+ )]
492
+ async fn recognize_document(
493
  State(state): State<ApiState>,
494
  Path(document_id): Path<String>,
495
  ) -> ApiResult<StatusCode> {
496
  let resources = state.resources()?;
497
  let (index, _) = find_document(&resources, &document_id).await?;
498
+ operations::ocr(resources, koharu_core::IndexPayload { index }).await?;
499
  Ok(StatusCode::NO_CONTENT)
500
  }
501
 
502
+ #[utoipa::path(
503
+ post,
504
+ path = "/documents/{document_id}/inpaint",
505
+ operation_id = "inpaintDocument",
506
+ tag = "processing",
507
+ params(("document_id" = String, Path,)),
508
+ responses(
509
+ (status = 204),
510
+ (status = 404, body = ApiError),
511
+ (status = 503, body = ApiError),
512
+ ),
513
+ )]
514
  async fn inpaint_document(
515
  State(state): State<ApiState>,
516
  Path(document_id): Path<String>,
517
  ) -> ApiResult<StatusCode> {
518
  let resources = state.resources()?;
519
  let (index, _) = find_document(&resources, &document_id).await?;
520
+ operations::inpaint(resources, koharu_core::IndexPayload { index }).await?;
521
  Ok(StatusCode::NO_CONTENT)
522
  }
523
 
524
+ #[utoipa::path(
525
+ post,
526
+ path = "/documents/{document_id}/render",
527
+ operation_id = "renderDocument",
528
+ tag = "processing",
529
+ params(("document_id" = String, Path,)),
530
+ request_body = RenderRequest,
531
+ responses(
532
+ (status = 204),
533
+ (status = 404, body = ApiError),
534
+ (status = 503, body = ApiError),
535
+ ),
536
+ )]
537
  async fn render_document(
538
  State(state): State<ApiState>,
539
  Path(document_id): Path<String>,
 
549
 
550
  operations::render(
551
  resources,
552
+ koharu_core::RenderPayload {
553
  index,
554
  text_block_index,
555
  shader_effect: request.shader_effect,
 
562
  Ok(StatusCode::NO_CONTENT)
563
  }
564
 
565
+ #[utoipa::path(
566
+ post,
567
+ path = "/documents/{document_id}/translate",
568
+ operation_id = "translateDocument",
569
+ tag = "processing",
570
+ params(("document_id" = String, Path,)),
571
+ request_body = TranslateRequest,
572
+ responses(
573
+ (status = 204),
574
+ (status = 404, body = ApiError),
575
+ (status = 503, body = ApiError),
576
+ ),
577
+ )]
578
  async fn translate_document(
579
  State(state): State<ApiState>,
580
  Path(document_id): Path<String>,
 
601
  Ok(StatusCode::NO_CONTENT)
602
  }
603
 
604
+ // ---------------------------------------------------------------------------
605
+ // Regions
606
+ // ---------------------------------------------------------------------------
607
+
608
+ #[utoipa::path(
609
+ put,
610
+ path = "/documents/{document_id}/mask",
611
+ operation_id = "updateMask",
612
+ tag = "regions",
613
+ params(("document_id" = String, Path,)),
614
+ request_body = MaskRegionRequest,
615
+ responses(
616
+ (status = 204),
617
+ (status = 404, body = ApiError),
618
+ (status = 503, body = ApiError),
619
+ ),
620
+ )]
621
+ async fn update_mask(
622
  State(state): State<ApiState>,
623
  Path(document_id): Path<String>,
624
  Json(request): Json<MaskRegionRequest>,
 
627
  let (index, _) = find_document(&resources, &document_id).await?;
628
  operations::update_inpaint_mask(
629
  resources,
630
+ koharu_core::UpdateInpaintMaskPayload {
631
  index,
632
  mask: request.data,
633
  region: request.region.map(to_inpaint_region),
 
637
  Ok(StatusCode::NO_CONTENT)
638
  }
639
 
640
+ #[utoipa::path(
641
+ put,
642
+ path = "/documents/{document_id}/brush-layer",
643
+ operation_id = "updateBrushLayer",
644
+ tag = "regions",
645
+ params(("document_id" = String, Path,)),
646
+ responses(
647
+ (status = 204),
648
+ (status = 404, body = ApiError),
649
+ (status = 503, body = ApiError),
650
+ ),
651
+ )]
652
+ async fn update_brush_layer(
653
  State(state): State<ApiState>,
654
  Path(document_id): Path<String>,
655
  Json(request): Json<koharu_core::BrushRegionRequest>,
 
658
  let (index, _) = find_document(&resources, &document_id).await?;
659
  operations::update_brush_layer(
660
  resources,
661
+ koharu_core::UpdateBrushLayerPayload {
662
  index,
663
  patch: request.data,
664
  region: to_inpaint_region(request.region),
 
668
  Ok(StatusCode::NO_CONTENT)
669
  }
670
 
671
+ #[utoipa::path(
672
+ post,
673
+ path = "/documents/{document_id}/inpaint-region",
674
+ operation_id = "inpaintRegion",
675
+ tag = "regions",
676
+ params(("document_id" = String, Path,)),
677
+ responses(
678
+ (status = 204),
679
+ (status = 404, body = ApiError),
680
+ (status = 503, body = ApiError),
681
+ ),
682
+ )]
683
  async fn inpaint_region(
684
  State(state): State<ApiState>,
685
  Path(document_id): Path<String>,
 
689
  let (index, _) = find_document(&resources, &document_id).await?;
690
  operations::inpaint_partial(
691
  resources,
692
+ koharu_core::InpaintPartialPayload {
693
  index,
694
  region: to_inpaint_region(request.region),
695
  },
 
698
  Ok(StatusCode::NO_CONTENT)
699
  }
700
 
701
+ // ---------------------------------------------------------------------------
702
+ // Text Blocks
703
+ // ---------------------------------------------------------------------------
704
+
705
+ #[utoipa::path(
706
+ post,
707
+ path = "/documents/{document_id}/text-blocks",
708
+ operation_id = "createTextBlock",
709
+ tag = "text-blocks",
710
+ params(("document_id" = String, Path,)),
711
+ request_body = CreateTextBlock,
712
+ responses(
713
+ (status = 200, body = TextBlockDetail),
714
+ (status = 404, body = ApiError),
715
+ (status = 503, body = ApiError),
716
+ ),
717
+ )]
718
  async fn create_text_block(
719
  State(state): State<ApiState>,
720
  Path(document_id): Path<String>,
 
750
  Ok(Json(detail))
751
  }
752
 
753
+ #[utoipa::path(
754
+ put,
755
+ path = "/documents/{document_id}/text-blocks",
756
+ operation_id = "putTextBlocks",
757
+ tag = "text-blocks",
758
+ params(("document_id" = String, Path,)),
759
+ request_body = Vec<TextBlockInput>,
760
+ responses(
761
+ (status = 204),
762
+ (status = 404, body = ApiError),
763
+ (status = 503, body = ApiError),
764
+ ),
765
+ )]
766
+ async fn put_text_blocks(
767
+ State(state): State<ApiState>,
768
+ Path(document_id): Path<String>,
769
+ Json(inputs): Json<Vec<TextBlockInput>>,
770
+ ) -> ApiResult<StatusCode> {
771
+ let resources = state.resources()?;
772
+ let (index, _) = find_document(&resources, &document_id).await?;
773
+
774
+ let any_content_changed = state_tx::mutate_doc(
775
+ &resources.state,
776
+ index,
777
+ &[ChangedField::TextBlocks],
778
+ |document| {
779
+ let mut any_changed = false;
780
+
781
+ // Build a set of incoming IDs for deletion detection
782
+ let incoming_ids: std::collections::HashSet<&str> = inputs
783
+ .iter()
784
+ .filter_map(|input| input.id.as_deref())
785
+ .collect();
786
+
787
+ // Delete blocks not present in the incoming array
788
+ let before_len = document.text_blocks.len();
789
+ document
790
+ .text_blocks
791
+ .retain(|block| incoming_ids.contains(block.id.as_str()));
792
+ if document.text_blocks.len() != before_len {
793
+ any_changed = true;
794
+ }
795
+
796
+ for input in &inputs {
797
+ if let Some(ref id) = input.id {
798
+ // Update existing block
799
+ if let Some(block) = document
800
+ .text_blocks
801
+ .iter_mut()
802
+ .find(|b| &b.id == id)
803
+ {
804
+ let patch = TextBlockPatch {
805
+ text: input.text.clone(),
806
+ translation: input.translation.clone(),
807
+ x: Some(input.x),
808
+ y: Some(input.y),
809
+ width: Some(input.width),
810
+ height: Some(input.height),
811
+ style: input.style.clone(),
812
+ };
813
+ let had_render = block.rendered.is_some();
814
+ apply_text_block_patch(block, patch);
815
+ // Content changed if the render was invalidated
816
+ if had_render && block.rendered.is_none() {
817
+ any_changed = true;
818
+ }
819
+ }
820
+ } else {
821
+ // Create new block
822
+ let mut block = TextBlock {
823
+ x: input.x,
824
+ y: input.y,
825
+ width: input.width,
826
+ height: input.height,
827
+ text: input.text.clone(),
828
+ translation: input.translation.clone(),
829
+ style: input.style.clone(),
830
+ confidence: 1.0,
831
+ ..Default::default()
832
+ };
833
+ block.set_layout_seed(block.x, block.y, block.width, block.height);
834
+ document.text_blocks.push(block);
835
+ any_changed = true;
836
+ }
837
+ }
838
+
839
+ Ok(any_changed)
840
+ },
841
+ )
842
+ .await?;
843
+
844
+ // Auto-render if any block content/geometry changed
845
+ if any_content_changed {
846
+ let _ = operations::render(
847
+ resources,
848
+ koharu_core::RenderPayload {
849
+ index,
850
+ text_block_index: None,
851
+ shader_effect: None,
852
+ shader_stroke: None,
853
+ font_family: None,
854
+ },
855
+ )
856
+ .await;
857
+ }
858
+
859
+ Ok(StatusCode::NO_CONTENT)
860
+ }
861
+
862
+ #[utoipa::path(
863
+ patch,
864
+ path = "/documents/{document_id}/text-blocks/{text_block_id}",
865
+ operation_id = "patchTextBlock",
866
+ tag = "text-blocks",
867
+ params(
868
+ ("document_id" = String, Path,),
869
+ ("text_block_id" = String, Path,),
870
+ ),
871
+ request_body = TextBlockPatch,
872
+ responses(
873
+ (status = 200, body = TextBlockDetail),
874
+ (status = 404, body = ApiError),
875
+ (status = 503, body = ApiError),
876
+ ),
877
+ )]
878
  async fn patch_text_block(
879
  State(state): State<ApiState>,
880
  Path((document_id, text_block_id)): Path<(String, String)>,
 
902
  Ok(Json(detail))
903
  }
904
 
905
+ #[utoipa::path(
906
+ delete,
907
+ path = "/documents/{document_id}/text-blocks/{text_block_id}",
908
+ operation_id = "deleteTextBlock",
909
+ tag = "text-blocks",
910
+ params(
911
+ ("document_id" = String, Path,),
912
+ ("text_block_id" = String, Path,),
913
+ ),
914
+ responses(
915
+ (status = 204),
916
+ (status = 404, body = ApiError),
917
+ (status = 503, body = ApiError),
918
+ ),
919
+ )]
920
  async fn delete_text_block(
921
  State(state): State<ApiState>,
922
  Path((document_id, text_block_id)): Path<(String, String)>,
 
943
  Ok(StatusCode::NO_CONTENT)
944
  }
945
 
946
+ // ---------------------------------------------------------------------------
947
+ // LLM
948
+ // ---------------------------------------------------------------------------
949
+
950
+ #[utoipa::path(
951
+ get,
952
+ path = "/llm/models",
953
+ operation_id = "listLlmModels",
954
+ tag = "llm",
955
+ params(LlmModelsQuery),
956
+ responses(
957
+ (status = 200, body = Vec<LlmModelInfo>),
958
+ (status = 503, body = ApiError),
959
+ ),
960
+ )]
961
  async fn list_llm_models(
962
  State(state): State<ApiState>,
963
  Query(query): Query<LlmModelsQuery>,
 
981
  Ok(Json(models))
982
  }
983
 
984
+ #[utoipa::path(
985
+ get,
986
+ path = "/llm",
987
+ operation_id = "getLlm",
988
+ tag = "llm",
989
+ responses(
990
+ (status = 200, body = koharu_core::LlmState),
991
+ (status = 503, body = ApiError),
992
+ ),
993
+ )]
994
+ async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
995
  let resources = state.resources()?;
996
  Ok(Json(resources.llm.snapshot().await))
997
  }
998
 
999
+ #[utoipa::path(
1000
+ put,
1001
+ path = "/llm",
1002
+ operation_id = "loadLlm",
1003
+ tag = "llm",
1004
+ request_body = LlmLoadRequest,
1005
+ responses(
1006
+ (status = 200, body = koharu_core::LlmState),
1007
+ (status = 400, body = ApiError),
1008
+ (status = 503, body = ApiError),
1009
+ ),
1010
+ )]
1011
  async fn load_llm(
1012
  State(state): State<ApiState>,
1013
  Json(request): Json<LlmLoadRequest>,
 
1015
  let resources = state.resources()?;
1016
  operations::llm_load(
1017
  resources.clone(),
1018
+ koharu_core::LlmLoadPayload {
1019
  id: request.id,
1020
  api_key: request.api_key,
1021
  base_url: request.base_url,
 
1028
  Ok(Json(resources.llm.snapshot().await))
1029
  }
1030
 
1031
+ #[utoipa::path(
1032
+ delete,
1033
+ path = "/llm",
1034
+ operation_id = "unloadLlm",
1035
+ tag = "llm",
1036
+ responses(
1037
+ (status = 200, body = koharu_core::LlmState),
1038
+ (status = 503, body = ApiError),
1039
+ ),
1040
+ )]
1041
+ async fn unload_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
1042
  let resources = state.resources()?;
1043
  operations::llm_offload(resources.clone()).await?;
1044
  Ok(Json(resources.llm.snapshot().await))
1045
  }
1046
 
1047
+ #[utoipa::path(
1048
+ post,
1049
+ path = "/llm/health",
1050
+ operation_id = "checkLlmHealth",
1051
+ tag = "llm",
1052
+ request_body = LlmPingRequest,
1053
+ responses(
1054
+ (status = 200, body = LlmPingResponse),
1055
+ ),
1056
+ )]
1057
+ async fn check_llm_health(
1058
  State(state): State<ApiState>,
1059
  Json(request): Json<LlmPingRequest>,
1060
  ) -> ApiResult<Json<LlmPingResponse>> {
 
1080
  }
1081
  }
1082
 
1083
+ // ---------------------------------------------------------------------------
1084
+ // Providers
1085
+ // ---------------------------------------------------------------------------
1086
+
1087
+ #[utoipa::path(
1088
+ get,
1089
+ path = "/providers/{provider}/api-key",
1090
+ operation_id = "getApiKey",
1091
+ tag = "providers",
1092
+ params(("provider" = String, Path,)),
1093
+ responses(
1094
+ (status = 200, body = ApiKeyResponse),
1095
+ (status = 503, body = ApiError),
1096
+ ),
1097
+ )]
1098
  async fn get_api_key(
1099
  State(state): State<ApiState>,
1100
  Path(provider): Path<String>,
1101
  ) -> ApiResult<Json<ApiKeyResponse>> {
1102
  let resources = state.resources()?;
1103
+ let result =
1104
+ operations::get_api_key(resources, koharu_core::ApiKeyGetPayload { provider }).await?;
1105
  Ok(Json(ApiKeyResponse {
1106
  api_key: result.api_key,
1107
  }))
1108
  }
1109
 
1110
+ #[utoipa::path(
1111
+ put,
1112
+ path = "/providers/{provider}/api-key",
1113
+ operation_id = "setApiKey",
1114
+ tag = "providers",
1115
+ params(("provider" = String, Path,)),
1116
+ request_body = ApiKeyValue,
1117
+ responses(
1118
+ (status = 204),
1119
+ (status = 503, body = ApiError),
1120
+ ),
1121
+ )]
1122
  async fn set_api_key(
1123
  State(state): State<ApiState>,
1124
  Path(provider): Path<String>,
 
1127
  let resources = state.resources()?;
1128
  operations::set_api_key(
1129
  resources,
1130
+ koharu_core::ApiKeySetPayload {
1131
  provider,
1132
  api_key: request.api_key,
1133
  },
 
1136
  Ok(StatusCode::NO_CONTENT)
1137
  }
1138
 
1139
+ // ---------------------------------------------------------------------------
1140
+ // Jobs
1141
+ // ---------------------------------------------------------------------------
1142
+
1143
+ #[utoipa::path(
1144
+ post,
1145
+ path = "/jobs/pipeline",
1146
+ operation_id = "startPipeline",
1147
+ tag = "jobs",
1148
+ request_body = PipelineJobRequest,
1149
+ responses(
1150
+ (status = 200, body = JobState),
1151
+ (status = 503, body = ApiError),
1152
+ ),
1153
+ )]
1154
+ async fn start_pipeline(
1155
  State(state): State<ApiState>,
1156
  Json(request): Json<PipelineJobRequest>,
1157
  ) -> ApiResult<Json<JobState>> {
 
1196
  overall_percent: 0,
1197
  error: None,
1198
  };
1199
+ state.tracker.publish_job(job.clone()).await;
1200
 
1201
  Ok(Json(job))
1202
  }
1203
 
1204
+ #[utoipa::path(
1205
+ get,
1206
+ path = "/jobs",
1207
+ operation_id = "listJobs",
1208
+ tag = "jobs",
1209
+ responses(
1210
+ (status = 200, body = Vec<JobState>),
1211
+ ),
1212
+ )]
1213
+ async fn list_jobs(State(state): State<ApiState>) -> Json<Vec<JobState>> {
1214
+ Json(state.tracker.list_jobs().await)
1215
+ }
1216
+
1217
+ #[utoipa::path(
1218
+ get,
1219
+ path = "/jobs/{job_id}",
1220
+ operation_id = "getJob",
1221
+ tag = "jobs",
1222
+ params(("job_id" = String, Path,)),
1223
+ responses(
1224
+ (status = 200, body = JobState),
1225
+ (status = 404, body = ApiError),
1226
+ ),
1227
+ )]
1228
+ async fn get_job(
1229
+ State(state): State<ApiState>,
1230
+ Path(job_id): Path<String>,
1231
+ ) -> ApiResult<Json<JobState>> {
1232
+ state
1233
+ .tracker
1234
+ .get_job(&job_id)
1235
+ .await
1236
+ .map(Json)
1237
+ .ok_or_else(|| ApiError::not_found(format!("Job not found: {job_id}")))
1238
+ }
1239
+
1240
+ #[utoipa::path(
1241
+ delete,
1242
+ path = "/jobs/{job_id}",
1243
+ operation_id = "cancelJob",
1244
+ tag = "jobs",
1245
+ params(("job_id" = String, Path,)),
1246
+ responses(
1247
+ (status = 204),
1248
+ (status = 404, body = ApiError),
1249
+ (status = 503, body = ApiError),
1250
+ ),
1251
+ )]
1252
+ async fn cancel_job(
1253
  State(state): State<ApiState>,
1254
  Path(job_id): Path<String>,
1255
  ) -> ApiResult<StatusCode> {
 
1269
  Ok(StatusCode::NO_CONTENT)
1270
  }
1271
 
1272
+ // ---------------------------------------------------------------------------
1273
+ // Downloads
1274
+ // ---------------------------------------------------------------------------
1275
+
1276
+ #[utoipa::path(
1277
+ get,
1278
+ path = "/downloads",
1279
+ operation_id = "listDownloads",
1280
+ tag = "downloads",
1281
+ responses(
1282
+ (status = 200, body = Vec<DownloadState>),
1283
+ ),
1284
+ )]
1285
+ async fn list_downloads(State(state): State<ApiState>) -> Json<Vec<DownloadState>> {
1286
+ Json(state.tracker.list_downloads().await)
1287
+ }
1288
+
1289
+ // ---------------------------------------------------------------------------
1290
+ // Exports
1291
+ // ---------------------------------------------------------------------------
1292
+
1293
+ #[utoipa::path(
1294
+ get,
1295
+ path = "/documents/{document_id}/export/{format}",
1296
+ operation_id = "exportDocument",
1297
+ tag = "exports",
1298
+ params(
1299
+ ("document_id" = String, Path,),
1300
+ ("format" = String, Path,),
1301
+ ExportQuery,
1302
+ ),
1303
+ responses(
1304
+ (status = 200, content_type = "application/octet-stream", body = inline(String)),
1305
+ (status = 404, body = ApiError),
1306
+ (status = 503, body = ApiError),
1307
+ ),
1308
+ )]
1309
  async fn export_document(
1310
  State(state): State<ApiState>,
1311
+ Path((document_id, format)): Path<(String, String)>,
1312
+ Query(query): Query<ExportQuery>,
1313
  ) -> ApiResult<Response> {
1314
  let resources = state.resources()?;
1315
  let (_, document) = find_document(&resources, &document_id).await?;
1316
+
1317
+ if format == "psd" {
1318
+ let data = koharu_psd::export_document(&document, &app_psd_export_options())
1319
+ .map_err(|error| ApiError::bad_request(error.to_string()))?;
1320
+ return Ok(binary_response(
1321
+ data,
1322
+ "image/vnd.adobe.photoshop",
1323
+ Some(psd_export_filename(&document)),
1324
+ ));
1325
+ }
1326
+
1327
  let layer = query.layer.unwrap_or(ExportLayer::Rendered);
1328
  let (image, filename) = export_target(&document, layer)?;
1329
  let ext = document
 
1337
  Ok(binary_response(data, content_type, Some(filename)))
1338
  }
1339
 
1340
+ #[utoipa::path(
1341
+ post,
1342
+ path = "/exports",
1343
+ operation_id = "batchExport",
1344
+ tag = "exports",
1345
+ request_body = ExportBatchRequest,
1346
+ responses(
1347
+ (status = 200, body = ExportResult),
1348
+ (status = 503, body = ApiError),
1349
+ ),
1350
+ )]
1351
+ async fn batch_export(
1352
  State(state): State<ApiState>,
1353
+ Json(request): Json<ExportBatchRequest>,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1354
  ) -> ApiResult<Json<ExportResult>> {
1355
  let resources = state.resources()?;
1356
+ let count = match request.layer.unwrap_or(ExportLayer::Rendered) {
1357
  ExportLayer::Rendered => operations::export_all_rendered(resources).await?,
1358
  ExportLayer::Inpainted => operations::export_all_inpainted(resources).await?,
1359
  };
1360
  Ok(Json(ExportResult { count }))
1361
  }
1362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1363
  async fn find_document(
1364
  resources: &AppResources,
1365
  document_id: &str,
 
1381
  .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))
1382
  }
1383
 
1384
+ fn encode_webp(image: &koharu_core::SerializableDynamicImage) -> ApiResult<Vec<u8>> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  encode_image(image, "webp")
1386
  }
1387
 
1388
+ fn encode_image(image: &koharu_core::SerializableDynamicImage, ext: &str) -> ApiResult<Vec<u8>> {
1389
  let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg);
1390
  let mut cursor = Cursor::new(Vec::new());
1391
  image
 
1421
  fn export_target(
1422
  document: &Document,
1423
  layer: ExportLayer,
1424
+ ) -> ApiResult<(&koharu_core::SerializableDynamicImage, String)> {
1425
  let ext = document
1426
  .path
1427
  .extension()
koharu-rpc/src/bin/openapi.rs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fn main() {
2
+ let (_, mut spec) = koharu_rpc::api::api();
3
+
4
+ // Register schemas that are referenced via IntoParams but not auto-collected
5
+ let extras = utoipa::openapi::OpenApiBuilder::new()
6
+ .components(Some(
7
+ utoipa::openapi::ComponentsBuilder::new()
8
+ .schema_from::<koharu_core::ImportMode>()
9
+ .schema_from::<koharu_core::ExportLayer>()
10
+ .build(),
11
+ ))
12
+ .build();
13
+ spec.merge(extras);
14
+
15
+ println!("{}", spec.to_pretty_json().unwrap());
16
+ }
koharu-rpc/src/lib.rs CHANGED
@@ -1,7 +1,7 @@
1
  pub mod api;
2
- pub mod events;
3
  mod mcp;
4
  pub mod server;
5
  pub mod shared;
 
6
 
7
  pub use shared::{BootstrapHooks, SharedState};
 
1
  pub mod api;
 
2
  mod mcp;
3
  pub mod server;
4
  pub mod shared;
5
+ pub mod tracker;
6
 
7
  pub use shared::{BootstrapHooks, SharedState};
koharu-rpc/src/server.rs CHANGED
@@ -14,9 +14,9 @@ use tokio::net::TcpListener;
14
  use tower_http::cors::CorsLayer;
15
 
16
  use crate::api;
17
- use crate::events::EventHub;
18
  use crate::mcp::KoharuMcp;
19
  use crate::shared::SharedState;
 
20
 
21
  /// An asset returned by the resolver: raw bytes + MIME type.
22
  pub struct Asset {
@@ -36,7 +36,7 @@ where
36
  }
37
 
38
  fn build_router(shared: SharedState, resolver: SharedAssetResolver) -> Router {
39
- let events = EventHub::new(shared.clone());
40
  let cors = CorsLayer::very_permissive();
41
 
42
  let mcp_service = StreamableHttpService::new(
@@ -52,7 +52,7 @@ fn build_router(shared: SharedState, resolver: SharedAssetResolver) -> Router {
52
  );
53
 
54
  Router::new()
55
- .nest("/api/v1", api::router(shared.clone(), events))
56
  .nest_service("/mcp", mcp_service)
57
  .layer(cors)
58
  .fallback(move |uri: Uri| {
 
14
  use tower_http::cors::CorsLayer;
15
 
16
  use crate::api;
 
17
  use crate::mcp::KoharuMcp;
18
  use crate::shared::SharedState;
19
+ use crate::tracker::Tracker;
20
 
21
  /// An asset returned by the resolver: raw bytes + MIME type.
22
  pub struct Asset {
 
36
  }
37
 
38
  fn build_router(shared: SharedState, resolver: SharedAssetResolver) -> Router {
39
+ let tracker = Tracker::new(&shared);
40
  let cors = CorsLayer::very_permissive();
41
 
42
  let mcp_service = StreamableHttpService::new(
 
52
  );
53
 
54
  Router::new()
55
+ .nest("/api/v1", api::router(shared.clone(), tracker))
56
  .nest_service("/mcp", mcp_service)
57
  .layer(cors)
58
  .fallback(move |uri: Uri| {
koharu-rpc/src/{events.rs → tracker.rs} RENAMED
@@ -1,158 +1,57 @@
1
- use std::{collections::HashMap, sync::Arc, time::Duration};
2
 
3
- use koharu_app::{pipeline, state_tx};
4
  use koharu_core::{
5
- DocumentChangedEvent, DocumentSummary, DocumentsChangedEvent, DownloadState, DownloadStatus,
6
- JobState, JobStatus, LlmState, PipelineProgress, PipelineStatus, PipelineStep, SnapshotEvent,
7
- TransferStatus,
8
  };
9
- use tokio::sync::{RwLock, broadcast};
10
 
11
- use crate::shared::{SharedState, get_resources};
12
-
13
- #[derive(Debug, Clone)]
14
- pub enum ApiEvent {
15
- DocumentsChanged(DocumentsChangedEvent),
16
- DocumentChanged(DocumentChangedEvent),
17
- JobChanged(JobState),
18
- DownloadChanged(DownloadState),
19
- LlmChanged(LlmState),
20
- }
21
 
 
22
  #[derive(Clone)]
23
- pub struct EventHub {
24
  inner: Arc<Inner>,
25
  }
26
 
27
  struct Inner {
28
- shared: SharedState,
29
- tx: broadcast::Sender<ApiEvent>,
30
  jobs: RwLock<HashMap<String, JobState>>,
31
  downloads: RwLock<HashMap<String, DownloadState>>,
32
  }
33
 
34
- impl EventHub {
35
- pub fn new(shared: SharedState) -> Self {
36
  let inner = Arc::new(Inner {
37
- shared,
38
- tx: broadcast::channel(256).0,
39
  jobs: RwLock::new(HashMap::new()),
40
  downloads: RwLock::new(HashMap::new()),
41
  });
42
 
43
- spawn_state_listener(inner.clone());
44
  spawn_pipeline_listener(inner.clone());
45
- spawn_download_listener(inner.clone());
46
- spawn_llm_listener(inner.clone());
47
 
48
  Self { inner }
49
  }
50
 
51
- pub fn subscribe(&self) -> broadcast::Receiver<ApiEvent> {
52
- self.inner.tx.subscribe()
 
 
53
  }
54
 
55
- pub async fn snapshot(&self) -> anyhow::Result<SnapshotEvent> {
56
- let (documents, llm) = if let Ok(resources) = get_resources(&self.inner.shared) {
57
- let guard = resources.state.read().await;
58
- let documents = guard.documents.iter().map(DocumentSummary::from).collect();
59
- drop(guard);
60
- (documents, resources.llm.snapshot().await)
61
- } else {
62
- (
63
- Vec::new(),
64
- LlmState {
65
- status: koharu_core::LlmStateStatus::Empty,
66
- model_id: None,
67
- source: None,
68
- error: None,
69
- },
70
- )
71
- };
72
-
73
- let mut jobs = self
74
- .inner
75
- .jobs
76
- .read()
77
- .await
78
- .values()
79
- .cloned()
80
- .collect::<Vec<_>>();
81
- jobs.sort_by(|left, right| left.id.cmp(&right.id));
82
-
83
- let mut downloads = self
84
- .inner
85
- .downloads
86
- .read()
87
- .await
88
- .values()
89
- .cloned()
90
- .collect::<Vec<_>>();
91
- downloads.sort_by(|left, right| left.filename.cmp(&right.filename));
92
-
93
- Ok(SnapshotEvent {
94
- documents,
95
- llm,
96
- jobs,
97
- downloads,
98
- })
99
  }
100
 
101
  pub async fn publish_job(&self, job: JobState) {
102
  update_job_state(&self.inner, job).await;
103
  }
104
- }
105
 
106
- fn spawn_state_listener(inner: Arc<Inner>) {
107
- let mut rx = state_tx::subscribe();
108
- tokio::spawn(async move {
109
- loop {
110
- match rx.recv().await {
111
- Ok(state_tx::StateEvent::DocumentsChanged) => {
112
- let Ok(resources) = get_resources(&inner.shared) else {
113
- continue;
114
- };
115
- let guard = resources.state.read().await;
116
- let documents = guard.documents.iter().map(DocumentSummary::from).collect();
117
- drop(guard);
118
- emit(
119
- &inner,
120
- ApiEvent::DocumentsChanged(DocumentsChangedEvent { documents }),
121
- );
122
- }
123
- Ok(state_tx::StateEvent::DocumentChanged {
124
- document_id,
125
- revision,
126
- changed,
127
- }) => {
128
- emit(
129
- &inner,
130
- ApiEvent::DocumentChanged(DocumentChangedEvent {
131
- document_id,
132
- revision,
133
- changed,
134
- }),
135
- );
136
- }
137
- Err(broadcast::error::RecvError::Lagged(n)) => {
138
- tracing::warn!(
139
- "State event listener lagged by {n} events, re-emitting documents"
140
- );
141
- let Ok(resources) = get_resources(&inner.shared) else {
142
- continue;
143
- };
144
- let guard = resources.state.read().await;
145
- let documents = guard.documents.iter().map(DocumentSummary::from).collect();
146
- drop(guard);
147
- emit(
148
- &inner,
149
- ApiEvent::DocumentsChanged(DocumentsChangedEvent { documents }),
150
- );
151
- }
152
- Err(broadcast::error::RecvError::Closed) => break,
153
- }
154
- }
155
- });
156
  }
157
 
158
  fn spawn_pipeline_listener(inner: Arc<Inner>) {
@@ -163,16 +62,16 @@ fn spawn_pipeline_listener(inner: Arc<Inner>) {
163
  Ok(progress) => {
164
  update_job_state(&inner, pipeline_job_state(progress)).await;
165
  }
166
- Err(broadcast::error::RecvError::Lagged(_)) => continue,
167
- Err(broadcast::error::RecvError::Closed) => break,
168
  }
169
  }
170
  });
171
  }
172
 
173
- fn spawn_download_listener(inner: Arc<Inner>) {
174
  tokio::spawn(async move {
175
- let mut runtime_rx = inner.shared.subscribe_runtime();
176
  let mut download_rx = runtime_rx.borrow().clone().subscribe_downloads();
177
 
178
  loop {
@@ -188,8 +87,8 @@ fn spawn_download_listener(inner: Arc<Inner>) {
188
  Ok(progress) => {
189
  update_download_state(&inner, download_state(progress)).await;
190
  }
191
- Err(broadcast::error::RecvError::Lagged(_)) => continue,
192
- Err(broadcast::error::RecvError::Closed) => {
193
  download_rx = runtime_rx.borrow().clone().subscribe_downloads();
194
  }
195
  }
@@ -198,41 +97,14 @@ fn spawn_download_listener(inner: Arc<Inner>) {
198
  });
199
  }
200
 
201
- fn spawn_llm_listener(inner: Arc<Inner>) {
202
- tokio::spawn(async move {
203
- let resources = loop {
204
- if let Ok(resources) = get_resources(&inner.shared) {
205
- break resources;
206
- }
207
- tokio::time::sleep(Duration::from_millis(100)).await;
208
- };
209
-
210
- let mut rx = resources.llm.subscribe();
211
- loop {
212
- match rx.recv().await {
213
- Ok(state) => emit(&inner, ApiEvent::LlmChanged(state)),
214
- Err(broadcast::error::RecvError::Lagged(_)) => continue,
215
- Err(broadcast::error::RecvError::Closed) => break,
216
- }
217
- }
218
- });
219
- }
220
-
221
- fn emit(inner: &Arc<Inner>, event: ApiEvent) {
222
- let _ = inner.tx.send(event);
223
- }
224
-
225
  async fn update_job_state(inner: &Arc<Inner>, job: JobState) {
226
  let terminal = !matches!(job.status, JobStatus::Running);
227
- {
228
- let mut jobs = inner.jobs.write().await;
229
- if terminal {
230
- jobs.remove(&job.id);
231
- } else {
232
- jobs.insert(job.id.clone(), job.clone());
233
- }
234
  }
235
- emit(inner, ApiEvent::JobChanged(job));
236
  }
237
 
238
  async fn update_download_state(inner: &Arc<Inner>, download: DownloadState) {
@@ -240,15 +112,12 @@ async fn update_download_state(inner: &Arc<Inner>, download: DownloadState) {
240
  download.status,
241
  TransferStatus::Started | TransferStatus::Downloading
242
  );
243
- {
244
- let mut downloads = inner.downloads.write().await;
245
- if terminal {
246
- downloads.remove(&download.id);
247
- } else {
248
- downloads.insert(download.id.clone(), download.clone());
249
- }
250
  }
251
- emit(inner, ApiEvent::DownloadChanged(download));
252
  }
253
 
254
  fn pipeline_job_state(progress: PipelineProgress) -> JobState {
@@ -295,8 +164,6 @@ fn download_state(progress: DownloadProgress) -> DownloadState {
295
  }
296
  }
297
 
298
- use koharu_core::DownloadProgress;
299
-
300
  #[cfg(test)]
301
  mod tests {
302
  use koharu_core::{PipelineStatus, PipelineStep};
 
1
+ use std::{collections::HashMap, sync::Arc};
2
 
3
+ use koharu_app::pipeline;
4
  use koharu_core::{
5
+ DownloadProgress, DownloadState, DownloadStatus, JobState, JobStatus, PipelineProgress,
6
+ PipelineStatus, PipelineStep, TransferStatus,
 
7
  };
8
+ use tokio::sync::RwLock;
9
 
10
+ use crate::shared::SharedState;
 
 
 
 
 
 
 
 
 
11
 
12
+ /// Tracks active jobs and downloads for polling endpoints.
13
  #[derive(Clone)]
14
+ pub struct Tracker {
15
  inner: Arc<Inner>,
16
  }
17
 
18
  struct Inner {
 
 
19
  jobs: RwLock<HashMap<String, JobState>>,
20
  downloads: RwLock<HashMap<String, DownloadState>>,
21
  }
22
 
23
+ impl Tracker {
24
+ pub fn new(shared: &SharedState) -> Self {
25
  let inner = Arc::new(Inner {
 
 
26
  jobs: RwLock::new(HashMap::new()),
27
  downloads: RwLock::new(HashMap::new()),
28
  });
29
 
 
30
  spawn_pipeline_listener(inner.clone());
31
+ spawn_download_listener(inner.clone(), shared.clone());
 
32
 
33
  Self { inner }
34
  }
35
 
36
+ pub async fn list_jobs(&self) -> Vec<JobState> {
37
+ let mut jobs: Vec<_> = self.inner.jobs.read().await.values().cloned().collect();
38
+ jobs.sort_by(|a, b| a.id.cmp(&b.id));
39
+ jobs
40
  }
41
 
42
+ pub async fn get_job(&self, job_id: &str) -> Option<JobState> {
43
+ self.inner.jobs.read().await.get(job_id).cloned()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  pub async fn publish_job(&self, job: JobState) {
47
  update_job_state(&self.inner, job).await;
48
  }
 
49
 
50
+ pub async fn list_downloads(&self) -> Vec<DownloadState> {
51
+ let mut downloads: Vec<_> = self.inner.downloads.read().await.values().cloned().collect();
52
+ downloads.sort_by(|a, b| a.filename.cmp(&b.filename));
53
+ downloads
54
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  fn spawn_pipeline_listener(inner: Arc<Inner>) {
 
62
  Ok(progress) => {
63
  update_job_state(&inner, pipeline_job_state(progress)).await;
64
  }
65
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
66
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
67
  }
68
  }
69
  });
70
  }
71
 
72
+ fn spawn_download_listener(inner: Arc<Inner>, shared: SharedState) {
73
  tokio::spawn(async move {
74
+ let mut runtime_rx = shared.subscribe_runtime();
75
  let mut download_rx = runtime_rx.borrow().clone().subscribe_downloads();
76
 
77
  loop {
 
87
  Ok(progress) => {
88
  update_download_state(&inner, download_state(progress)).await;
89
  }
90
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
91
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {
92
  download_rx = runtime_rx.borrow().clone().subscribe_downloads();
93
  }
94
  }
 
97
  });
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  async fn update_job_state(inner: &Arc<Inner>, job: JobState) {
101
  let terminal = !matches!(job.status, JobStatus::Running);
102
+ let mut jobs = inner.jobs.write().await;
103
+ if terminal {
104
+ jobs.remove(&job.id);
105
+ } else {
106
+ jobs.insert(job.id.clone(), job);
 
 
107
  }
 
108
  }
109
 
110
  async fn update_download_state(inner: &Arc<Inner>, download: DownloadState) {
 
112
  download.status,
113
  TransferStatus::Started | TransferStatus::Downloading
114
  );
115
+ let mut downloads = inner.downloads.write().await;
116
+ if terminal {
117
+ downloads.remove(&download.id);
118
+ } else {
119
+ downloads.insert(download.id.clone(), download);
 
 
120
  }
 
121
  }
122
 
123
  fn pipeline_job_state(progress: PipelineProgress) -> JobState {
 
164
  }
165
  }
166
 
 
 
167
  #[cfg(test)]
168
  mod tests {
169
  use koharu_core::{PipelineStatus, PipelineStep};
ui/app/(app)/about/page.tsx CHANGED
@@ -11,8 +11,7 @@ import {
11
  } from 'lucide-react'
12
  import { ScrollArea } from '@/components/ui/scroll-area'
13
  import { isTauri } from '@/lib/backend'
14
- import { api } from '@/lib/api'
15
- import { useDocumentMutations } from '@/lib/query/mutations'
16
  import Image from 'next/image'
17
 
18
  const GITHUB_REPO = 'mayocream/koharu'
@@ -21,7 +20,9 @@ type VersionStatus = 'loading' | 'latest' | 'outdated' | 'error'
21
 
22
  export default function AboutPage() {
23
  const { t } = useTranslation()
24
- const { openExternal } = useDocumentMutations()
 
 
25
 
26
  const [appVersion, setAppVersion] = useState<string>()
27
  const [latestVersion, setLatestVersion] = useState<string>()
@@ -31,7 +32,8 @@ export default function AboutPage() {
31
  const checkVersion = async () => {
32
  try {
33
  if (isTauri()) {
34
- const version = await api.appVersion()
 
35
  setAppVersion(version)
36
 
37
  const res = await fetch(
 
11
  } from 'lucide-react'
12
  import { ScrollArea } from '@/components/ui/scroll-area'
13
  import { isTauri } from '@/lib/backend'
14
+ import { getMeta } from '@/lib/api/system/system'
 
15
  import Image from 'next/image'
16
 
17
  const GITHUB_REPO = 'mayocream/koharu'
 
20
 
21
  export default function AboutPage() {
22
  const { t } = useTranslation()
23
+ const openExternal = (url: string) => {
24
+ window.open(url, '_blank', 'noopener,noreferrer')
25
+ }
26
 
27
  const [appVersion, setAppVersion] = useState<string>()
28
  const [latestVersion, setLatestVersion] = useState<string>()
 
32
  const checkVersion = async () => {
33
  try {
34
  if (isTauri()) {
35
+ const meta = await getMeta()
36
+ const version = meta.version
37
  setAppVersion(version)
38
 
39
  const res = await fetch(
ui/app/(app)/settings/page.tsx CHANGED
@@ -10,12 +10,8 @@ import {
10
  MonitorIcon,
11
  ChevronLeftIcon,
12
  ChevronRightIcon,
13
- ChevronDownIcon,
14
  EyeIcon,
15
  EyeOffIcon,
16
- CheckCircleIcon,
17
- XCircleIcon,
18
- LoaderIcon,
19
  } from 'lucide-react'
20
  import { ScrollArea } from '@/components/ui/scroll-area'
21
  import {
@@ -26,14 +22,11 @@ import {
26
  SelectValue,
27
  } from '@/components/ui/select'
28
  import { isTauri } from '@/lib/backend'
29
- import { api } from '@/lib/api'
30
- import {
31
- usePreferencesStore,
32
- getActivePresetConfig,
33
- type LocalLlmPreset,
34
- } from '@/lib/stores/preferencesStore'
35
  import { supportedLanguages } from '@/lib/i18n'
36
- import type { BootstrapConfig } from '@/lib/protocol'
37
 
38
  const THEME_OPTIONS = [
39
  { value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
@@ -54,16 +47,6 @@ const API_PROVIDERS: ApiProvider[] = [
54
  { id: 'deepseek', name: 'DeepSeek', free_tier: false },
55
  ]
56
 
57
- const PRESET_BUTTONS: { value: LocalLlmPreset; labelKey: string }[] = [
58
- { value: 'ollama', labelKey: 'settings.localLlmPresetOllama' },
59
- { value: 'lmstudio', labelKey: 'settings.localLlmPresetLmStudio' },
60
- { value: 'preset1', labelKey: 'settings.localLlmPresetPreset1' },
61
- { value: 'preset2', labelKey: 'settings.localLlmPresetPreset2' },
62
- ]
63
-
64
- const DEFAULT_SYSTEM_PROMPT =
65
- 'You are a professional manga translator. Translate Japanese manga dialogue into natural {target_language} that fits inside speech bubbles. Preserve character voice, emotional tone, relationship nuance, emphasis, and sound effects naturally. Keep the wording concise. Do not add notes, explanations, or romanization. If the input contains <block id="N">...</block>, translate only the text inside each block. Keep every block tag exactly unchanged, including ids, order, and block count. Do not merge blocks, split blocks, or add any text outside the blocks.'
66
-
67
  const inputClass =
68
  'border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none'
69
 
@@ -74,9 +57,6 @@ export default function SettingsPage() {
74
  const [deviceInfo, setDeviceInfo] = useState<{ mlDevice: string }>()
75
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
76
  const setApiKey = usePreferencesStore((state) => state.setApiKey)
77
- const localLlm = usePreferencesStore((state) => state.localLlm)
78
- const setLocalLlm = usePreferencesStore((state) => state.setLocalLlm)
79
- const setActivePreset = usePreferencesStore((state) => state.setActivePreset)
80
  const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
81
  const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
82
  {},
@@ -85,23 +65,16 @@ export default function SettingsPage() {
85
  const proxySaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
86
  const pendingBootstrapConfigRef = useRef<BootstrapConfig | null>(null)
87
 
88
- const [showAdvanced, setShowAdvanced] = useState(false)
89
- const [pingState, setPingState] = useState<{
90
- loading: boolean
91
- result?: { ok: boolean; count: number; latency: number; error?: string }
92
- }>({ loading: false })
93
  const [bootstrapConfig, setBootstrapConfig] =
94
  useState<BootstrapConfig | null>(null)
95
 
96
- const activeConfig = getActivePresetConfig(localLlm)
97
-
98
  useEffect(() => {
99
  if (!isTauri()) return
100
 
101
  const loadDeviceInfo = async () => {
102
  try {
103
- const info = await api.deviceInfo()
104
- setDeviceInfo(info)
105
  } catch (error) {
106
  console.error('Failed to load device info', error)
107
  }
@@ -113,7 +86,7 @@ export default function SettingsPage() {
113
  useEffect(() => {
114
  const loadBootstrapConfig = async () => {
115
  try {
116
- const config = await api.getBootstrapConfig()
117
  setBootstrapConfig(config)
118
  } catch (error) {
119
  console.error('Failed to load bootstrap config', error)
@@ -125,7 +98,7 @@ export default function SettingsPage() {
125
 
126
  const persistApiKey = async (provider: string, value: string) => {
127
  try {
128
- await api.setApiKey(provider, value)
129
  } catch (error) {
130
  console.error(`Failed to save API key for ${provider}`, error)
131
  }
@@ -133,7 +106,7 @@ export default function SettingsPage() {
133
 
134
  const persistBootstrapConfig = async (nextConfig: BootstrapConfig) => {
135
  try {
136
- const saved = await api.saveBootstrapConfig(nextConfig)
137
  setBootstrapConfig(saved)
138
  } catch (error) {
139
  console.error('Failed to save bootstrap config', error)
@@ -219,40 +192,6 @@ export default function SettingsPage() {
219
  }, 300)
220
  }
221
 
222
- const handleTestConnection = async () => {
223
- setPingState({ loading: true })
224
- try {
225
- const result = await api.llmPing(
226
- activeConfig.baseUrl,
227
- activeConfig.apiKey || undefined,
228
- )
229
- setPingState({
230
- loading: false,
231
- result: {
232
- ok: result.ok,
233
- count: result.models.length,
234
- latency: result.latencyMs ?? 0,
235
- error: result.error,
236
- },
237
- })
238
- } catch (error) {
239
- setPingState({
240
- loading: false,
241
- result: {
242
- ok: false,
243
- count: 0,
244
- latency: 0,
245
- error: String(error),
246
- },
247
- })
248
- }
249
- }
250
-
251
- const handlePresetChange = (preset: LocalLlmPreset) => {
252
- setActivePreset(preset)
253
- setPingState({ loading: false })
254
- }
255
-
256
  return (
257
  <div className='bg-muted flex min-h-0 flex-1 flex-col overflow-hidden'>
258
  <ScrollArea className='min-h-0 flex-1' viewportClassName='h-full'>
@@ -427,247 +366,6 @@ export default function SettingsPage() {
427
  </div>
428
  </section>
429
 
430
- {/* Local LLM & OpenAI Compatible Providers Section */}
431
- <section className='mb-8'>
432
- <h2 className='text-foreground mb-1 text-sm font-bold'>
433
- {t('settings.localLlmTitle')}
434
- </h2>
435
- <p className='text-muted-foreground mb-4 text-sm'>
436
- {t('settings.localLlmDescription')}
437
- </p>
438
-
439
- <div className='space-y-3'>
440
- {/* Provider Preset */}
441
- <div className='space-y-1'>
442
- <label className='text-foreground text-sm'>
443
- {t('settings.localLlmPreset')}
444
- </label>
445
- <div className='grid grid-cols-4 gap-2'>
446
- {PRESET_BUTTONS.map(({ value, labelKey }) => (
447
- <button
448
- key={value}
449
- onClick={() => handlePresetChange(value)}
450
- data-active={localLlm.activePreset === value}
451
- className='border-border bg-card text-muted-foreground hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground rounded-lg border px-3 py-2 text-sm font-medium transition'
452
- >
453
- {t(labelKey)}
454
- </button>
455
- ))}
456
- </div>
457
- </div>
458
-
459
- {/* Base URL */}
460
- <div className='space-y-1'>
461
- <label className='text-foreground text-sm'>
462
- {t('settings.localLlmBaseUrl')}
463
- </label>
464
- <input
465
- type='url'
466
- value={activeConfig.baseUrl}
467
- onChange={(e) => setLocalLlm({ baseUrl: e.target.value })}
468
- placeholder='http://127.0.0.1:1234/v1'
469
- className={inputClass}
470
- />
471
- </div>
472
-
473
- {/* API Key */}
474
- <div className='space-y-1'>
475
- <label className='text-foreground text-sm'>
476
- {t('settings.localLlmApiKey')}
477
- </label>
478
- <div className='relative'>
479
- <input
480
- type={
481
- visibleKeys[`llm-${localLlm.activePreset}`]
482
- ? 'text'
483
- : 'password'
484
- }
485
- value={activeConfig.apiKey}
486
- onChange={(e) => setLocalLlm({ apiKey: e.target.value })}
487
- placeholder='API key'
488
- className={`${inputClass} pr-9`}
489
- />
490
- <button
491
- type='button'
492
- onClick={() =>
493
- setVisibleKeys((v) => ({
494
- ...v,
495
- [`llm-${localLlm.activePreset}`]:
496
- !v[`llm-${localLlm.activePreset}`],
497
- }))
498
- }
499
- className='text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 -translate-y-1/2 transition'
500
- >
501
- {visibleKeys[`llm-${localLlm.activePreset}`] ? (
502
- <EyeOffIcon className='size-4' />
503
- ) : (
504
- <EyeIcon className='size-4' />
505
- )}
506
- </button>
507
- </div>
508
- </div>
509
-
510
- {/* Model Name */}
511
- <div className='space-y-1'>
512
- <label className='text-foreground text-sm'>
513
- {t('settings.localLlmModelName')}
514
- </label>
515
- <input
516
- type='text'
517
- value={activeConfig.modelName}
518
- onChange={(e) => setLocalLlm({ modelName: e.target.value })}
519
- placeholder={t('settings.localLlmModelNamePlaceholder')}
520
- className={inputClass}
521
- />
522
- </div>
523
-
524
- {/* Advanced Section Toggle */}
525
- <button
526
- type='button'
527
- onClick={() => setShowAdvanced((v) => !v)}
528
- className='text-muted-foreground hover:text-foreground flex items-center gap-1 text-sm transition'
529
- >
530
- <ChevronDownIcon
531
- className={`size-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
532
- />
533
- {t('settings.localLlmAdvanced')}
534
- </button>
535
-
536
- {showAdvanced && (
537
- <div className='space-y-3 pl-1'>
538
- {/* Temperature */}
539
- <div className='space-y-1'>
540
- <label className='text-foreground text-sm'>
541
- {t('settings.localLlmTemperature')}
542
- </label>
543
- <input
544
- type='number'
545
- value={activeConfig.temperature ?? ''}
546
- onChange={(e) =>
547
- setLocalLlm({
548
- temperature:
549
- e.target.value === ''
550
- ? null
551
- : parseFloat(e.target.value),
552
- })
553
- }
554
- placeholder={t(
555
- 'settings.localLlmTemperaturePlaceholder',
556
- )}
557
- step={0.1}
558
- min={0}
559
- max={2}
560
- className={inputClass}
561
- />
562
- </div>
563
-
564
- {/* Max Tokens */}
565
- <div className='space-y-1'>
566
- <label className='text-foreground text-sm'>
567
- {t('settings.localLlmMaxTokens')}
568
- </label>
569
- <input
570
- type='number'
571
- value={activeConfig.maxTokens ?? ''}
572
- onChange={(e) =>
573
- setLocalLlm({
574
- maxTokens:
575
- e.target.value === ''
576
- ? null
577
- : parseInt(e.target.value, 10),
578
- })
579
- }
580
- placeholder={t('settings.localLlmMaxTokensPlaceholder')}
581
- step={100}
582
- min={1}
583
- className={inputClass}
584
- />
585
- </div>
586
-
587
- {/* Custom System Prompt */}
588
- <div className='space-y-1'>
589
- <div className='flex items-center justify-between'>
590
- <label className='text-foreground text-sm'>
591
- {t('settings.localLlmSystemPrompt')}
592
- </label>
593
- {activeConfig.customSystemPrompt && (
594
- <button
595
- type='button'
596
- onClick={() =>
597
- setLocalLlm({ customSystemPrompt: '' })
598
- }
599
- className='text-primary text-xs hover:underline'
600
- >
601
- {t('settings.localLlmSystemPromptReset')}
602
- </button>
603
- )}
604
- </div>
605
- <textarea
606
- value={activeConfig.customSystemPrompt}
607
- onChange={(e) =>
608
- setLocalLlm({
609
- customSystemPrompt: e.target.value,
610
- })
611
- }
612
- placeholder={DEFAULT_SYSTEM_PROMPT}
613
- rows={4}
614
- className={`${inputClass} resize-y`}
615
- />
616
- <span className='text-muted-foreground text-xs'>
617
- {t('settings.localLlmSystemPromptPlaceholder')}
618
- </span>
619
- </div>
620
- </div>
621
- )}
622
-
623
- {/* Test Connection */}
624
- <div className='space-y-2'>
625
- <button
626
- type='button'
627
- onClick={handleTestConnection}
628
- disabled={pingState.loading || !activeConfig.baseUrl.trim()}
629
- className='border-border bg-card text-foreground hover:bg-accent disabled:text-muted-foreground inline-flex items-center gap-2 rounded-md border px-4 py-1.5 text-sm font-medium transition disabled:opacity-50'
630
- >
631
- {pingState.loading ? (
632
- <>
633
- <LoaderIcon className='size-4 animate-spin' />
634
- {t('settings.localLlmTesting')}
635
- </>
636
- ) : (
637
- t('settings.localLlmTestConnection')
638
- )}
639
- </button>
640
-
641
- {pingState.result && !pingState.loading && (
642
- <div
643
- className={`flex items-start gap-2 text-sm ${pingState.result.ok ? 'text-green-500' : 'text-red-500'}`}
644
- >
645
- {pingState.result.ok ? (
646
- <>
647
- <CheckCircleIcon className='mt-0.5 size-4 shrink-0' />
648
- <span>
649
- {t('settings.localLlmTestSuccess', {
650
- count: pingState.result.count,
651
- latency: pingState.result.latency,
652
- })}
653
- </span>
654
- </>
655
- ) : (
656
- <>
657
- <XCircleIcon className='mt-0.5 size-4 shrink-0' />
658
- <span>
659
- {t('settings.localLlmTestFailed', {
660
- error: pingState.result.error,
661
- })}
662
- </span>
663
- </>
664
- )}
665
- </div>
666
- )}
667
- </div>
668
- </div>
669
- </section>
670
-
671
  {/* Divider */}
672
  <div className='border-border mb-8 border-t' />
673
 
 
10
  MonitorIcon,
11
  ChevronLeftIcon,
12
  ChevronRightIcon,
 
13
  EyeIcon,
14
  EyeOffIcon,
 
 
 
15
  } from 'lucide-react'
16
  import { ScrollArea } from '@/components/ui/scroll-area'
17
  import {
 
22
  SelectValue,
23
  } from '@/components/ui/select'
24
  import { isTauri } from '@/lib/backend'
25
+ import { getMeta, getConfig, updateConfig } from '@/lib/api/system/system'
26
+ import { setApiKey as setApiKeyApi } from '@/lib/api/providers/providers'
27
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
 
 
28
  import { supportedLanguages } from '@/lib/i18n'
29
+ import type { BootstrapConfig } from '@/lib/api/schemas'
30
 
31
  const THEME_OPTIONS = [
32
  { value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
 
47
  { id: 'deepseek', name: 'DeepSeek', free_tier: false },
48
  ]
49
 
 
 
 
 
 
 
 
 
 
 
50
  const inputClass =
51
  'border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none'
52
 
 
57
  const [deviceInfo, setDeviceInfo] = useState<{ mlDevice: string }>()
58
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
59
  const setApiKey = usePreferencesStore((state) => state.setApiKey)
 
 
 
60
  const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
61
  const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
62
  {},
 
65
  const proxySaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
66
  const pendingBootstrapConfigRef = useRef<BootstrapConfig | null>(null)
67
 
 
 
 
 
 
68
  const [bootstrapConfig, setBootstrapConfig] =
69
  useState<BootstrapConfig | null>(null)
70
 
 
 
71
  useEffect(() => {
72
  if (!isTauri()) return
73
 
74
  const loadDeviceInfo = async () => {
75
  try {
76
+ const meta = await getMeta()
77
+ setDeviceInfo({ mlDevice: meta.mlDevice })
78
  } catch (error) {
79
  console.error('Failed to load device info', error)
80
  }
 
86
  useEffect(() => {
87
  const loadBootstrapConfig = async () => {
88
  try {
89
+ const config = await getConfig()
90
  setBootstrapConfig(config)
91
  } catch (error) {
92
  console.error('Failed to load bootstrap config', error)
 
98
 
99
  const persistApiKey = async (provider: string, value: string) => {
100
  try {
101
+ await setApiKeyApi(provider, { apiKey: value })
102
  } catch (error) {
103
  console.error(`Failed to save API key for ${provider}`, error)
104
  }
 
106
 
107
  const persistBootstrapConfig = async (nextConfig: BootstrapConfig) => {
108
  try {
109
+ const saved = await updateConfig(nextConfig)
110
  setBootstrapConfig(saved)
111
  } catch (error) {
112
  console.error('Failed to save bootstrap config', error)
 
192
  }, 300)
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  return (
196
  <div className='bg-muted flex min-h-0 flex-1 flex-col overflow-hidden'>
197
  <ScrollArea className='min-h-0 flex-1' viewportClassName='h-full'>
 
366
  </div>
367
  </section>
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  {/* Divider */}
370
  <div className='border-border mb-8 border-t' />
371
 
ui/app/bootstrap/page.tsx CHANGED
@@ -18,10 +18,11 @@ import {
18
  SelectTrigger,
19
  SelectValue,
20
  } from '@/components/ui/select'
21
- import { api } from '@/lib/api'
22
- import { subscribeDownloadChanged, subscribeSnapshot } from '@/lib/backend'
 
 
23
  import i18n, { supportedLanguages } from '@/lib/i18n'
24
- import type { BootstrapConfig, DownloadState } from '@/lib/protocol'
25
 
26
  const DEFAULT_CONFIG: BootstrapConfig = {
27
  runtime: { path: '' },
@@ -77,9 +78,9 @@ export default function BootstrapPage() {
77
  setError(null)
78
 
79
  try {
80
- const saved = await api.saveBootstrapConfig(nextConfig)
81
  setConfig(saved)
82
- await api.initializeBootstrap()
83
  } catch (cause) {
84
  setInitializing(false)
85
  setFailed(true)
@@ -90,7 +91,7 @@ export default function BootstrapPage() {
90
  useEffect(() => {
91
  void (async () => {
92
  try {
93
- const saved = await api.getBootstrapConfig()
94
  setConfig(saved)
95
  } catch (cause) {
96
  const message =
@@ -105,39 +106,33 @@ export default function BootstrapPage() {
105
  }, [])
106
 
107
  useEffect(() => {
108
- const updateDownload = (progress: DownloadState) => {
109
- setDownload({
110
- filename: progress.filename,
111
- percent: computePercent(progress),
112
- failed: progress.status === 'failed',
113
- })
114
- }
115
-
116
- const unsubscribeSnapshot = subscribeSnapshot((snapshot) => {
117
- const active =
118
- snapshot.downloads.find(
119
- (entry) =>
120
- entry.status === 'started' || entry.status === 'downloading',
121
- ) ??
122
- snapshot.downloads
123
- .slice()
124
- .sort((left, right) => left.filename.localeCompare(right.filename))
125
- .at(-1) ??
126
- null
127
-
128
- if (active) {
129
- updateDownload(active)
 
130
  }
131
- })
132
 
133
- const unsubscribeDownload = subscribeDownloadChanged((progress) => {
134
- updateDownload(progress)
135
- })
136
-
137
- return () => {
138
- unsubscribeSnapshot()
139
- unsubscribeDownload()
140
- }
141
  }, [])
142
 
143
  const goNext = async () => {
 
18
  SelectTrigger,
19
  SelectValue,
20
  } from '@/components/ui/select'
21
+ import { getConfig, updateConfig, initialize } from '@/lib/api/system/system'
22
+ import { listDownloads } from '@/lib/api/downloads/downloads'
23
+ import type { DownloadState } from '@/lib/api/schemas'
24
+ import type { BootstrapConfig } from '@/lib/api/schemas'
25
  import i18n, { supportedLanguages } from '@/lib/i18n'
 
26
 
27
  const DEFAULT_CONFIG: BootstrapConfig = {
28
  runtime: { path: '' },
 
78
  setError(null)
79
 
80
  try {
81
+ const saved = await updateConfig(nextConfig)
82
  setConfig(saved)
83
+ await initialize()
84
  } catch (cause) {
85
  setInitializing(false)
86
  setFailed(true)
 
91
  useEffect(() => {
92
  void (async () => {
93
  try {
94
+ const saved = await getConfig()
95
  setConfig(saved)
96
  } catch (cause) {
97
  const message =
 
106
  }, [])
107
 
108
  useEffect(() => {
109
+ const interval = setInterval(async () => {
110
+ try {
111
+ const downloads = await listDownloads()
112
+ const active =
113
+ downloads.find(
114
+ (entry) =>
115
+ entry.status === 'started' || entry.status === 'downloading',
116
+ ) ??
117
+ downloads
118
+ .slice()
119
+ .sort((left, right) => left.filename.localeCompare(right.filename))
120
+ .at(-1) ??
121
+ null
122
+
123
+ if (active) {
124
+ setDownload({
125
+ filename: active.filename,
126
+ percent: computePercent(active),
127
+ failed: active.status === 'failed',
128
+ })
129
+ }
130
+ } catch {
131
+ // Backend may not be ready yet during bootstrap
132
  }
133
+ }, 1500)
134
 
135
+ return () => clearInterval(interval)
 
 
 
 
 
 
 
136
  }, [])
137
 
138
  const goNext = async () => {
ui/app/providers.tsx CHANGED
@@ -1,309 +1,74 @@
1
  'use client'
2
 
3
- import { useEffect, useRef, type ReactNode } from 'react'
4
  import { I18nextProvider } from 'react-i18next'
5
  import { ThemeProvider } from 'next-themes'
6
- import { QueryClientProvider, useQueryClient } from '@tanstack/react-query'
7
- import { usePathname } from 'next/navigation'
8
  import ClientOnly from '@/components/ClientOnly'
9
  import { TooltipProvider } from '@/components/ui/tooltip'
10
- import {
11
- ProgressBarStatus,
12
- getCurrentWindow,
13
- listen,
14
- subscribeDocumentChanged,
15
- subscribeDocumentsChanged,
16
- subscribeJobChanged,
17
- subscribeLlmChanged,
18
- subscribeSnapshot,
19
- } from '@/lib/backend'
20
  import i18n from '@/lib/i18n'
21
- import { getQueryClient } from '@/lib/query/client'
22
- import { queryKeys } from '@/lib/query/keys'
23
- import { useApiKeyQuery, useDocumentsCountQuery } from '@/lib/query/hooks'
24
- import { useDownloadStore } from '@/lib/downloads'
25
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
26
- import { useLlmUiStore } from '@/lib/stores/llmUiStore'
27
- import { useOperationStore } from '@/lib/stores/operationStore'
28
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
29
- import { isTauri } from '@/lib/backend'
30
- import { useRpcConnection } from '@/hooks/useRpcConnection'
31
- import type {
32
- DocumentSummary,
33
- JobState,
34
- LlmState,
35
- SnapshotEvent,
36
- } from '@/lib/protocol'
37
-
38
- function ProvidersBootstrap({ children }: { children: ReactNode }) {
39
- const queryClient = useQueryClient()
40
- const pathname = usePathname()
41
- const isStartupRoute =
42
- pathname === '/bootstrap' || pathname === '/splashscreen'
43
- const hasConnectedRef = useRef(false)
44
- const setTotalPages = useEditorUiStore((state) => state.setTotalPages)
45
- const setApiKey = usePreferencesStore((state) => state.setApiKey)
46
- const rpcConnected = useRpcConnection()
47
- const shouldQueryApiKeys = rpcConnected && !isStartupRoute && isTauri()
48
- const { data: documentsCount } = useDocumentsCountQuery(
49
- rpcConnected && !isStartupRoute,
50
- )
51
- const openAiApiKeyQuery = useApiKeyQuery('openai', shouldQueryApiKeys)
52
- const openAiCompatibleApiKeyQuery = useApiKeyQuery(
53
- 'openai-compatible',
54
- shouldQueryApiKeys,
55
- )
56
- const geminiApiKeyQuery = useApiKeyQuery('gemini', shouldQueryApiKeys)
57
- const claudeApiKeyQuery = useApiKeyQuery('claude', shouldQueryApiKeys)
58
- const deepSeekApiKeyQuery = useApiKeyQuery('deepseek', shouldQueryApiKeys)
59
-
60
- const applyDocumentsSnapshot = (documents: DocumentSummary[]) => {
61
- const count = documents.length
62
- useEditorUiStore.setState((state) => ({
63
- totalPages: count,
64
- currentDocumentIndex:
65
- count === 0 ? 0 : Math.min(state.currentDocumentIndex, count - 1),
66
- selectedBlockIndex: count === 0 ? undefined : state.selectedBlockIndex,
67
- documentsVersion: state.documentsVersion + 1,
68
- }))
69
- queryClient.setQueryData(queryKeys.documents.count, count)
70
- queryClient.invalidateQueries({
71
- queryKey: queryKeys.documents.currentRoot,
72
- })
73
- queryClient.invalidateQueries({
74
- queryKey: queryKeys.documents.thumbnailRoot,
75
- })
76
- }
77
-
78
- const applyLlmSnapshot = (llm: LlmState) => {
79
- const selectedModel = useLlmUiStore.getState().selectedModel
80
- const isReady =
81
- llm.status === 'ready' &&
82
- (!selectedModel || !llm.modelId || llm.modelId === selectedModel)
83
- queryClient.setQueryData(queryKeys.llm.ready(selectedModel), isReady)
84
- useLlmUiStore.getState().setLoading(llm.status === 'loading')
85
-
86
- if (llm.status !== 'loading') {
87
- const operation = useOperationStore.getState().operation
88
- if (operation?.type === 'llm-load') {
89
- useOperationStore.getState().finishOperation()
90
- getCurrentWindow()
91
- .setProgressBar({
92
- status: ProgressBarStatus.None,
93
- progress: 0,
94
- })
95
- .catch(() => {})
96
- }
97
- }
98
- }
99
-
100
- const updatePipelineUi = (job: JobState | null) => {
101
- const operationStore = useOperationStore.getState()
102
-
103
- if (!job) {
104
- return
105
- }
106
-
107
- if (job.status === 'running') {
108
- const isSingleDoc = job.totalDocuments <= 1
109
- operationStore.updateOperation({
110
- step: job.step ?? undefined,
111
- current: isSingleDoc
112
- ? job.currentStepIndex
113
- : job.currentDocument +
114
- (job.totalSteps > 0 ? job.currentStepIndex / job.totalSteps : 0),
115
- total: isSingleDoc ? job.totalSteps : job.totalDocuments,
116
- })
117
-
118
- getCurrentWindow()
119
- .setProgressBar({
120
- status: ProgressBarStatus.Normal,
121
- progress: job.overallPercent,
122
- })
123
- .catch(() => {})
124
- return
125
- }
126
-
127
- operationStore.updateOperation({
128
- current: operationStore.operation?.total,
129
- total: operationStore.operation?.total,
130
- })
131
-
132
- getCurrentWindow()
133
- .setProgressBar({ status: ProgressBarStatus.Normal, progress: 100 })
134
- .catch(() => {})
135
-
136
- queryClient.invalidateQueries({
137
- queryKey: queryKeys.documents.currentRoot,
138
- })
139
- queryClient.invalidateQueries({
140
- queryKey: queryKeys.documents.thumbnailRoot,
141
- })
142
-
143
- setTimeout(() => {
144
- useOperationStore.getState().finishOperation()
145
- getCurrentWindow()
146
- .setProgressBar({
147
- status: ProgressBarStatus.None,
148
- progress: 0,
149
- })
150
- .catch(() => {})
151
- }, 1000)
152
- }
153
-
154
- useEffect(() => {
155
- if (!rpcConnected) return
156
-
157
- if (hasConnectedRef.current) {
158
- queryClient.invalidateQueries({ type: 'active' })
159
- return
160
- }
161
-
162
- hasConnectedRef.current = true
163
- }, [queryClient, rpcConnected])
164
-
165
- useEffect(() => {
166
- if (typeof documentsCount === 'number') {
167
- setTotalPages(documentsCount)
168
- }
169
- }, [documentsCount, setTotalPages])
170
 
171
- useEffect(() => {
172
- if (openAiApiKeyQuery.status === 'success') {
173
- setApiKey('openai', openAiApiKeyQuery.data ?? '')
174
- }
175
- }, [openAiApiKeyQuery.data, openAiApiKeyQuery.status, setApiKey])
176
 
177
- useEffect(() => {
178
- if (openAiCompatibleApiKeyQuery.status === 'success') {
179
- setApiKey('openai-compatible', openAiCompatibleApiKeyQuery.data ?? '')
180
- }
181
- }, [
182
- openAiCompatibleApiKeyQuery.data,
183
- openAiCompatibleApiKeyQuery.status,
184
- setApiKey,
185
- ])
186
 
187
- useEffect(() => {
188
- if (geminiApiKeyQuery.status === 'success') {
189
- setApiKey('gemini', geminiApiKeyQuery.data ?? '')
190
- }
191
- }, [geminiApiKeyQuery.data, geminiApiKeyQuery.status, setApiKey])
192
 
193
- useEffect(() => {
194
- if (claudeApiKeyQuery.status === 'success') {
195
- setApiKey('claude', claudeApiKeyQuery.data ?? '')
196
- }
197
- }, [claudeApiKeyQuery.data, claudeApiKeyQuery.status, setApiKey])
 
 
 
198
 
199
- useEffect(() => {
200
- if (deepSeekApiKeyQuery.status === 'success') {
201
- setApiKey('deepseek', deepSeekApiKeyQuery.data ?? '')
202
- }
203
- }, [deepSeekApiKeyQuery.data, deepSeekApiKeyQuery.status, setApiKey])
 
 
 
 
 
 
 
 
204
 
205
  useEffect(() => {
206
- if (isStartupRoute) return
207
-
208
- let unlisten: (() => void) | undefined
209
- ;(async () => {
210
- try {
211
- unlisten = await listen<number>('documents:opened', (event) => {
212
- const count = event.payload ?? 0
213
- setTotalPages(count)
214
- queryClient.setQueryData(queryKeys.documents.count, count)
215
- queryClient.invalidateQueries({
216
- queryKey: queryKeys.documents.currentRoot,
217
- })
218
- queryClient.invalidateQueries({
219
- queryKey: queryKeys.documents.thumbnailRoot,
220
- })
221
- })
222
- } catch (_) {}
223
- })()
224
 
225
- const unsubscribeSnapshot = subscribeSnapshot((payload: SnapshotEvent) => {
226
- applyDocumentsSnapshot(payload.documents)
227
- applyLlmSnapshot(payload.llm)
228
- const pipelineJob =
229
- payload.jobs.find((job) => job.kind === 'pipeline') ?? null
230
- updatePipelineUi(pipelineJob)
231
- })
232
-
233
- const unsubscribeDocuments = subscribeDocumentsChanged((payload) => {
234
- applyDocumentsSnapshot(payload.documents)
235
- })
236
-
237
- const unsubscribeDocument = subscribeDocumentChanged(() => {
238
- queryClient.invalidateQueries({
239
- queryKey: queryKeys.documents.currentRoot,
240
- })
241
- queryClient.invalidateQueries({
242
- queryKey: queryKeys.documents.thumbnailRoot,
243
- })
244
- })
245
-
246
- const unsubscribeJobs = subscribeJobChanged((job) => {
247
- if (job.kind !== 'pipeline') return
248
- updatePipelineUi(job)
249
- queryClient.invalidateQueries({
250
- queryKey: queryKeys.documents.currentRoot,
251
- })
252
- queryClient.invalidateQueries({
253
- queryKey: queryKeys.documents.thumbnailRoot,
254
- })
255
- })
256
-
257
- const unsubscribeLlm = subscribeLlmChanged((llm) => {
258
- applyLlmSnapshot(llm)
259
- })
260
-
261
- return () => {
262
- unlisten?.()
263
- unsubscribeSnapshot()
264
- unsubscribeDocuments()
265
- unsubscribeDocument()
266
- unsubscribeJobs()
267
- unsubscribeLlm()
268
- }
269
- }, [isStartupRoute, queryClient, setTotalPages])
270
-
271
- return children
272
  }
273
 
274
  export function Providers({ children }: { children: ReactNode }) {
275
- const queryClient = getQueryClient()
276
- const ensureDownloadSubscribed = useDownloadStore(
277
- (state) => state.ensureSubscribed,
278
- )
279
-
280
  useEffect(() => {
281
- ensureDownloadSubscribed()
282
- }, [ensureDownloadSubscribed])
283
-
284
- useEffect(() => {
285
- const handleLanguageChange = (lng: string) => {
286
- document.documentElement.lang = lng
287
- }
288
-
289
- handleLanguageChange(i18n.language)
290
- i18n.on('languageChanged', handleLanguageChange)
291
- return () => {
292
- i18n.off('languageChanged', handleLanguageChange)
293
- }
294
  }, [])
295
 
296
  return (
297
  <QueryClientProvider client={queryClient}>
298
- <ThemeProvider attribute='class' defaultTheme='system' enableSystem>
299
- <ClientOnly>
300
- <ProvidersBootstrap>
 
301
  <I18nextProvider i18n={i18n}>
302
  <TooltipProvider delayDuration={0}>{children}</TooltipProvider>
303
  </I18nextProvider>
304
- </ProvidersBootstrap>
305
- </ClientOnly>
306
- </ThemeProvider>
307
  </QueryClientProvider>
308
  )
309
  }
 
1
  'use client'
2
 
3
+ import { useEffect, type ReactNode } from 'react'
4
  import { I18nextProvider } from 'react-i18next'
5
  import { ThemeProvider } from 'next-themes'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 
7
  import ClientOnly from '@/components/ClientOnly'
8
  import { TooltipProvider } from '@/components/ui/tooltip'
9
+ import { isTauri } from '@/lib/backend'
10
+ import { useGetApiKey } from '@/lib/api/providers/providers'
 
 
 
 
 
 
 
 
11
  import i18n from '@/lib/i18n'
 
 
 
 
 
 
 
12
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
13
+ import { ProcessingProvider } from '@/lib/machines'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
+ const queryClient = new QueryClient()
 
 
 
 
16
 
17
+ const API_KEY_PROVIDERS = ['openai', 'openai-compatible', 'gemini', 'claude', 'deepseek'] as const
 
 
 
 
 
 
 
 
18
 
19
+ function ApiKeySyncer() {
20
+ const setApiKey = usePreferencesStore((state) => state.setApiKey)
 
 
 
21
 
22
+ return (
23
+ <>
24
+ {API_KEY_PROVIDERS.map((provider) => (
25
+ <ApiKeySync key={provider} provider={provider} setApiKey={setApiKey} />
26
+ ))}
27
+ </>
28
+ )
29
+ }
30
 
31
+ function ApiKeySync({
32
+ provider,
33
+ setApiKey,
34
+ }: {
35
+ provider: string
36
+ setApiKey: (provider: string, key: string) => void
37
+ }) {
38
+ const { data, status } = useGetApiKey(provider, {
39
+ query: {
40
+ enabled: isTauri(),
41
+ select: (res: { apiKey?: string | null }) => res?.apiKey ?? '',
42
+ },
43
+ })
44
 
45
  useEffect(() => {
46
+ if (status === 'success') setApiKey(provider, data ?? '')
47
+ }, [data, status, provider, setApiKey])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ return null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  export function Providers({ children }: { children: ReactNode }) {
 
 
 
 
 
53
  useEffect(() => {
54
+ const onLang = (lng: string) => { document.documentElement.lang = lng }
55
+ onLang(i18n.language)
56
+ i18n.on('languageChanged', onLang)
57
+ return () => { i18n.off('languageChanged', onLang) }
 
 
 
 
 
 
 
 
 
58
  }, [])
59
 
60
  return (
61
  <QueryClientProvider client={queryClient}>
62
+ <ProcessingProvider>
63
+ <ThemeProvider attribute='class' defaultTheme='system' enableSystem>
64
+ <ClientOnly>
65
+ <ApiKeySyncer />
66
  <I18nextProvider i18n={i18n}>
67
  <TooltipProvider delayDuration={0}>{children}</TooltipProvider>
68
  </I18nextProvider>
69
+ </ClientOnly>
70
+ </ThemeProvider>
71
+ </ProcessingProvider>
72
  </QueryClientProvider>
73
  )
74
  }
ui/app/splashscreen/page.tsx CHANGED
@@ -1,9 +1,8 @@
1
  'use client'
2
 
3
- import { useEffect, useRef, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
- import { subscribeDownloadChanged } from '@/lib/backend'
6
- import type { DownloadState } from '@/lib/protocol'
7
 
8
  type AggregateProgress = {
9
  filename: string
@@ -13,65 +12,43 @@ type AggregateProgress = {
13
  export default function SplashScreen() {
14
  const { t } = useTranslation()
15
  const [progress, setProgress] = useState<AggregateProgress | null>(null)
16
- const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
17
- new Map(),
18
- )
19
 
20
  useEffect(() => {
21
- const unsub = subscribeDownloadChanged((msg: DownloadState) => {
22
- const files = filesRef.current
23
-
24
- if (msg.status === 'started') {
25
- files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
26
- } else if (msg.status === 'downloading') {
27
- const entry = files.get(msg.filename)
28
- if (entry) {
29
- entry.downloaded = msg.downloaded
30
- if (msg.total) entry.total = msg.total
31
- } else {
32
- files.set(msg.filename, {
33
- downloaded: msg.downloaded,
34
- total: msg.total ?? 0,
35
- })
36
- }
37
- } else {
38
- // Completed or Failed — lock this file at 100%
39
- const entry = files.get(msg.filename)
40
- if (entry) {
41
- entry.downloaded = entry.total
42
- }
43
- }
44
 
45
- // Compute aggregate
46
- let totalBytes = 0
47
- let downloadedBytes = 0
48
- for (const entry of files.values()) {
49
- totalBytes += entry.total
50
- downloadedBytes += entry.downloaded
51
- }
52
 
53
- // Find current active file (last non-completed)
54
- const activeFilename =
55
- msg.status === 'started' || msg.status === 'downloading'
56
- ? msg.filename
57
- : null
 
 
58
 
59
- const percent =
60
- totalBytes > 0
61
- ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
62
- : undefined
63
 
64
- if (activeFilename) {
65
- setProgress({ filename: activeFilename, percent })
66
- } else {
67
- // All done — keep showing 100% briefly
68
- setProgress((prev) =>
69
- prev ? { ...prev, percent: percent ?? 100 } : null,
70
- )
 
 
71
  }
72
- })
73
 
74
- return () => unsub()
75
  }, [])
76
 
77
  return (
 
1
  'use client'
2
 
3
+ import { useEffect, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { listDownloads } from '@/lib/api/downloads/downloads'
 
6
 
7
  type AggregateProgress = {
8
  filename: string
 
12
  export default function SplashScreen() {
13
  const { t } = useTranslation()
14
  const [progress, setProgress] = useState<AggregateProgress | null>(null)
 
 
 
15
 
16
  useEffect(() => {
17
+ const interval = setInterval(async () => {
18
+ try {
19
+ const downloads = await listDownloads()
20
+ if (downloads.length === 0) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ let totalBytes = 0
23
+ let downloadedBytes = 0
24
+ let activeFilename: string | null = null
 
 
 
 
25
 
26
+ for (const d of downloads) {
27
+ totalBytes += d.total ?? 0
28
+ downloadedBytes += d.downloaded
29
+ if (d.status === 'started' || d.status === 'downloading') {
30
+ activeFilename = d.filename
31
+ }
32
+ }
33
 
34
+ const percent =
35
+ totalBytes > 0
36
+ ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
37
+ : undefined
38
 
39
+ if (activeFilename) {
40
+ setProgress({ filename: activeFilename, percent })
41
+ } else {
42
+ setProgress((prev) =>
43
+ prev ? { ...prev, percent: percent ?? 100 } : null,
44
+ )
45
+ }
46
+ } catch {
47
+ // Backend may not be ready yet
48
  }
49
+ }, 1500)
50
 
51
+ return () => clearInterval(interval)
52
  }, [])
53
 
54
  return (
ui/components/ActivityBubble.tsx CHANGED
@@ -1,14 +1,13 @@
1
  'use client'
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']
14
 
@@ -115,40 +114,43 @@ function ErrorCard({
115
  )
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  function OperationCard({
119
- operation,
120
  onCancel,
 
121
  t,
122
  }: {
123
- operation: OperationState
124
  onCancel: () => void
 
125
  t: TranslateFunc
126
  }) {
127
- const isProcessAll = operation.type === 'process-all'
128
- const hasProgressNumbers =
129
- typeof operation.current === 'number' &&
130
- typeof operation.total === 'number' &&
131
- operation.total > 0
132
- const currentValue = hasProgressNumbers ? operation.current : undefined
133
- const total = hasProgressNumbers ? operation.total : undefined
134
  const progress = clampProgress(
135
- total && typeof currentValue === 'number'
136
- ? (currentValue / total) * 100
137
- : undefined,
138
  )
139
- const displayCurrent =
140
- total && typeof currentValue === 'number'
141
- ? Math.min(
142
- total,
143
- Math.floor(currentValue) + (currentValue >= total ? 0 : 1),
144
- )
145
- : undefined
146
- const titles: Record<OperationState['type'], string> = {
147
- 'load-khr': t('operations.loadKhr'),
148
- 'process-current': t('operations.processCurrent'),
149
- 'process-all': t('operations.processAll'),
150
- 'llm-load': t('operations.loadModel'),
151
- }
152
  const stepLabels: Record<string, string> = {
153
  detect: t('processing.detect'),
154
  ocr: t('processing.ocr'),
@@ -157,8 +159,8 @@ function OperationCard({
157
  render: t('processing.render'),
158
  }
159
 
160
- const stepLabel = operation.step
161
- ? (stepLabels[operation.step] ?? operation.step)
162
  : undefined
163
  const stepText =
164
  stepLabel && total && typeof displayCurrent === 'number'
@@ -170,34 +172,25 @@ function OperationCard({
170
  : undefined
171
 
172
  const imageText =
173
- operation.type === 'process-all' &&
174
- total &&
175
- typeof displayCurrent === 'number'
176
  ? t('operations.imageProgress', {
177
  current: displayCurrent,
178
  total,
179
  })
180
  : undefined
181
 
182
- const subtitleParts =
183
- operation.type === 'process-all'
184
- ? [stepLabel]
185
- : [imageText, stepText ?? stepLabel].filter(Boolean)
186
  const subtitle =
187
  subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
188
 
 
 
189
  return (
190
  <BubbleCard>
191
  <div
192
  data-testid='operation-card'
193
- data-operation-type={operation.type}
194
- data-cancel-requested={operation.cancelRequested ? 'true' : 'false'}
195
- data-current={
196
- typeof operation.current === 'number' ? String(operation.current) : ''
197
- }
198
- data-total={
199
- typeof operation.total === 'number' ? String(operation.total) : ''
200
- }
201
  className='flex items-start gap-3'
202
  >
203
  <div className='bg-primary mt-1 h-2.5 w-2.5 rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
@@ -205,13 +198,13 @@ function OperationCard({
205
  <div className='flex items-start justify-between gap-2'>
206
  <div className='flex flex-col gap-1'>
207
  <div className='text-foreground text-sm font-semibold'>
208
- {titles[operation.type] ?? t('operations.title')}
209
  </div>
210
  <div className='text-muted-foreground text-xs'>
211
  {subtitle || t('operations.inProgress')}
212
  </div>
213
  </div>
214
- {isProcessAll && total && typeof displayCurrent === 'number' ? (
215
  <span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[11px] font-medium'>
216
  {t('operations.imageProgress', {
217
  current: displayCurrent,
@@ -221,19 +214,16 @@ function OperationCard({
221
  ) : null}
222
  </div>
223
  <ProgressBar percent={progress} />
224
- {operation.cancellable && (
225
  <div className='mt-3 flex justify-end'>
226
  <Button
227
  data-testid='operation-cancel'
228
  variant='outline'
229
  size='sm'
230
  onClick={onCancel}
231
- disabled={operation.cancelRequested}
232
  className='text-xs font-semibold'
233
  >
234
- {operation.cancelRequested
235
- ? t('operations.cancelling')
236
- : t('operations.cancel')}
237
  </Button>
238
  </div>
239
  )}
@@ -245,30 +235,46 @@ function OperationCard({
245
 
246
  export function ActivityBubble() {
247
  const { t } = useTranslation()
248
- const operation = useOperationStore((state) => state.operation)
249
- const error = useUiErrorStore((state) => state.error)
250
- const clearError = useUiErrorStore((state) => state.clearError)
251
- const { cancelOperation } = useDocumentMutations()
252
- const downloads = useDownloadStore((s) => s.downloads)
253
- const ensureSubscribed = useDownloadStore((s) => s.ensureSubscribed)
254
 
255
- useEffect(() => {
256
- ensureSubscribed()
257
- }, [ensureSubscribed])
258
 
259
- const activeDownloads = Array.from(downloads.values()).filter(
260
- (d) => d.status === 'started' || d.status === 'downloading',
261
- )
262
 
263
- if (!error && !operation && activeDownloads.length === 0) return null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  return (
266
  <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'>
267
- {error && (
268
- <ErrorCard message={error.message} onDismiss={clearError} t={t} />
269
  )}
270
- {operation && (
271
- <OperationCard operation={operation} onCancel={cancelOperation} t={t} />
 
 
 
 
 
272
  )}
273
  {activeDownloads.map((d) => (
274
  <DownloadCard
 
1
  'use client'
2
 
3
+ import { type ReactNode } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
  import { CircleXIcon } from 'lucide-react'
6
+ import { useListDownloads } from '@/lib/api/downloads/downloads'
7
+ import type { DownloadState } from '@/lib/api/schemas'
8
  import { Button } from '@/components/ui/button'
9
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
+ import { useProcessing } from '@/lib/machines'
 
 
11
 
12
  type TranslateFunc = ReturnType<typeof useTranslation>['t']
13
 
 
114
  )
115
  }
116
 
117
+ /** Map machine state name to an operation type label key */
118
+ function getOperationTitle(
119
+ state: ReturnType<typeof useProcessing>['state'],
120
+ t: TranslateFunc,
121
+ ): string {
122
+ if (state.matches('importing')) return t('operations.loadKhr')
123
+ if (state.matches('loadingLlm')) return t('operations.loadModel')
124
+ if (state.matches('pipeline')) {
125
+ const docId = state.context.documentId
126
+ return docId ? t('operations.processCurrent') : t('operations.processAll')
127
+ }
128
+ return t('operations.processCurrent')
129
+ }
130
+
131
  function OperationCard({
132
+ machineState,
133
  onCancel,
134
+ canCancel,
135
  t,
136
  }: {
137
+ machineState: ReturnType<typeof useProcessing>['state']
138
  onCancel: () => void
139
+ canCancel: boolean
140
  t: TranslateFunc
141
  }) {
142
+ const ctx = machineState.context
143
+ const hasProgressNumbers = ctx.total > 0
 
 
 
 
 
144
  const progress = clampProgress(
145
+ hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined,
 
 
146
  )
147
+ const displayCurrent = hasProgressNumbers
148
+ ? Math.min(ctx.total, Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1))
149
+ : undefined
150
+ const total = hasProgressNumbers ? ctx.total : undefined
151
+
152
+ const isPipelineAll = machineState.matches('pipeline') && !ctx.documentId
153
+
 
 
 
 
 
 
154
  const stepLabels: Record<string, string> = {
155
  detect: t('processing.detect'),
156
  ocr: t('processing.ocr'),
 
159
  render: t('processing.render'),
160
  }
161
 
162
+ const stepLabel = ctx.step
163
+ ? (stepLabels[ctx.step] ?? ctx.step)
164
  : undefined
165
  const stepText =
166
  stepLabel && total && typeof displayCurrent === 'number'
 
172
  : undefined
173
 
174
  const imageText =
175
+ isPipelineAll && total && typeof displayCurrent === 'number'
 
 
176
  ? t('operations.imageProgress', {
177
  current: displayCurrent,
178
  total,
179
  })
180
  : undefined
181
 
182
+ const subtitleParts = isPipelineAll
183
+ ? [stepLabel]
184
+ : [imageText, stepText ?? stepLabel].filter(Boolean)
 
185
  const subtitle =
186
  subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
187
 
188
+ const title = getOperationTitle(machineState, t)
189
+
190
  return (
191
  <BubbleCard>
192
  <div
193
  data-testid='operation-card'
 
 
 
 
 
 
 
 
194
  className='flex items-start gap-3'
195
  >
196
  <div className='bg-primary mt-1 h-2.5 w-2.5 rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
 
198
  <div className='flex items-start justify-between gap-2'>
199
  <div className='flex flex-col gap-1'>
200
  <div className='text-foreground text-sm font-semibold'>
201
+ {title}
202
  </div>
203
  <div className='text-muted-foreground text-xs'>
204
  {subtitle || t('operations.inProgress')}
205
  </div>
206
  </div>
207
+ {isPipelineAll && total && typeof displayCurrent === 'number' ? (
208
  <span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[11px] font-medium'>
209
  {t('operations.imageProgress', {
210
  current: displayCurrent,
 
214
  ) : null}
215
  </div>
216
  <ProgressBar percent={progress} />
217
+ {canCancel && (
218
  <div className='mt-3 flex justify-end'>
219
  <Button
220
  data-testid='operation-cancel'
221
  variant='outline'
222
  size='sm'
223
  onClick={onCancel}
 
224
  className='text-xs font-semibold'
225
  >
226
+ {t('operations.cancel')}
 
 
227
  </Button>
228
  </div>
229
  )}
 
235
 
236
  export function ActivityBubble() {
237
  const { t } = useTranslation()
238
+ const { isProcessing, state: machineState, send, canCancel, error: machineError } = useProcessing()
239
+ const uiError = useEditorUiStore((state) => state.error)
240
+ const clearUiError = useEditorUiStore((state) => state.clearError)
 
 
 
241
 
242
+ const handleCancel = () => {
243
+ send({ type: 'CANCEL' })
244
+ }
245
 
246
+ const { data: allDownloads = [] } = useListDownloads({
247
+ query: { refetchInterval: 2000 },
248
+ })
249
 
250
+ const activeDownloads = allDownloads
251
+ .filter((d) => d.status === 'started' || d.status === 'downloading')
252
+ .map((d) => ({
253
+ ...d,
254
+ percent:
255
+ d.total && d.total > 0
256
+ ? Math.round((d.downloaded / d.total) * 100)
257
+ : undefined,
258
+ }))
259
+
260
+ // Show machine errors as well as UI errors
261
+ const errorMessage = machineError ?? uiError?.message
262
+ const clearError = machineError ? () => { /* machine clears on next operation */ } : clearUiError
263
+
264
+ if (!errorMessage && !isProcessing && 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
+ {errorMessage && (
269
+ <ErrorCard message={errorMessage} onDismiss={clearError} t={t} />
270
  )}
271
+ {isProcessing && (
272
+ <OperationCard
273
+ machineState={machineState}
274
+ onCancel={handleCancel}
275
+ canCancel={canCancel}
276
+ t={t}
277
+ />
278
  )}
279
  {activeDownloads.map((d) => (
280
  <DownloadCard
ui/components/AppErrorBoundary.tsx CHANGED
@@ -1,14 +1,13 @@
1
  'use client'
2
 
3
  import { type ReactNode } from 'react'
 
4
  import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
5
  import { Button } from '@/components/ui/button'
6
- import { getQueryClient } from '@/lib/query/client'
7
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
8
- import { useLlmUiStore } from '@/lib/stores/llmUiStore'
9
- import { useOperationStore } from '@/lib/stores/operationStore'
10
 
11
  function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
 
12
  const errorMessage =
13
  error instanceof Error ? error.message : 'Unexpected error'
14
 
@@ -27,8 +26,6 @@ function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
27
  variant='outline'
28
  onClick={() => {
29
  useEditorUiStore.getState().resetUiState()
30
- useLlmUiStore.getState().resetLlmUiState()
31
- useOperationStore.getState().resetOperationState()
32
  resetErrorBoundary()
33
  }}
34
  >
@@ -38,7 +35,7 @@ function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
38
  size='sm'
39
  variant='outline'
40
  onClick={() => {
41
- getQueryClient().clear()
42
  resetErrorBoundary()
43
  }}
44
  >
 
1
  'use client'
2
 
3
  import { type ReactNode } from 'react'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
  import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
6
  import { Button } from '@/components/ui/button'
 
7
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
 
 
8
 
9
  function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
10
+ const queryClient = useQueryClient()
11
  const errorMessage =
12
  error instanceof Error ? error.message : 'Unexpected error'
13
 
 
26
  variant='outline'
27
  onClick={() => {
28
  useEditorUiStore.getState().resetUiState()
 
 
29
  resetErrorBoundary()
30
  }}
31
  >
 
35
  size='sm'
36
  variant='outline'
37
  onClick={() => {
38
+ queryClient.clear()
39
  resetErrorBoundary()
40
  }}
41
  >
ui/components/MenuBar.tsx CHANGED
@@ -3,8 +3,31 @@
3
  import { useCallback, useEffect, useState } from 'react'
4
  import Link from 'next/link'
5
  import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
6
- import { isTauri, isMacOS, windowControls } from '@/lib/backend'
7
  import { useTranslation } from 'react-i18next'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
9
  import Image from 'next/image'
10
  import {
@@ -15,7 +38,10 @@ import {
15
  MenubarSeparator,
16
  MenubarTrigger,
17
  } from '@/components/ui/menubar'
18
- import { useDocumentMutations } from '@/lib/query/mutations'
 
 
 
19
 
20
  type MenuItem = {
21
  label: string
@@ -30,62 +56,74 @@ type MenuSection = {
30
  triggerTestId?: string
31
  }
32
 
 
 
 
 
33
  export function MenuBar() {
34
  const { t } = useTranslation()
35
- const {
36
- addDocuments,
37
- openDocuments,
38
- openFolder,
39
- addFolder,
40
- openExternal,
41
- processImage,
42
- inpaintAndRenderImage,
43
- processAllImages,
44
- exportDocument,
45
- exportPsdDocument,
46
- exportAllInpainted,
47
- exportAllRendered,
48
- } = useDocumentMutations()
 
 
 
 
 
 
 
 
49
 
50
  const fileMenuItems: MenuItem[] = [
51
  {
52
  label: t('menu.openFile'),
53
- onSelect: openDocuments,
54
  testId: 'menu-file-open',
55
  },
56
  {
57
  label: t('menu.addFile'),
58
- onSelect: addDocuments,
59
  testId: 'menu-file-add',
60
  },
61
  {
62
  label: t('menu.openFolder'),
63
- onSelect: openFolder,
64
  testId: 'menu-file-open-folder',
65
  },
66
  {
67
  label: t('menu.addFolder'),
68
- onSelect: addFolder,
69
  testId: 'menu-file-add-folder',
70
  },
71
  {
72
  label: t('menu.export'),
73
- onSelect: exportDocument,
74
  testId: 'menu-file-export',
75
  },
76
  {
77
  label: t('menu.exportPsd'),
78
- onSelect: exportPsdDocument,
79
  testId: 'menu-file-export-psd',
80
  },
81
  {
82
  label: t('menu.exportAllInpainted'),
83
- onSelect: exportAllInpainted,
84
  testId: 'menu-file-export-all-inpainted',
85
  },
86
  {
87
  label: t('menu.exportAllRendered'),
88
- onSelect: exportAllRendered,
89
  testId: 'menu-file-export-all-rendered',
90
  },
91
  ]
@@ -104,17 +142,23 @@ export function MenuBar() {
104
  items: [
105
  {
106
  label: t('menu.processCurrent'),
107
- onSelect: processImage,
 
 
 
108
  testId: 'menu-process-current',
109
  },
110
  {
111
  label: t('menu.redoInpaintRender'),
112
- onSelect: inpaintAndRenderImage,
 
 
 
113
  testId: 'menu-process-rerender',
114
  },
115
  {
116
  label: t('menu.processAll'),
117
- onSelect: processAllImages,
118
  testId: 'menu-process-all',
119
  },
120
  ],
 
3
  import { useCallback, useEffect, useState } from 'react'
4
  import Link from 'next/link'
5
  import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
6
+ import { isTauri } from '@/lib/backend'
7
  import { useTranslation } from 'react-i18next'
8
+
9
+ const isMacOS = () =>
10
+ typeof navigator !== 'undefined' &&
11
+ /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
12
+
13
+ const windowControls = {
14
+ async close() {
15
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
16
+ return getCurrentWindow().close()
17
+ },
18
+ async minimize() {
19
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
20
+ return getCurrentWindow().minimize()
21
+ },
22
+ async toggleMaximize() {
23
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
24
+ return getCurrentWindow().toggleMaximize()
25
+ },
26
+ async isMaximized() {
27
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
28
+ return getCurrentWindow().isMaximized()
29
+ },
30
+ }
31
  import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
32
  import Image from 'next/image'
33
  import {
 
38
  MenubarSeparator,
39
  MenubarTrigger,
40
  } from '@/components/ui/menubar'
41
+ import { useProcessing } from '@/lib/machines'
42
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
43
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
44
+ import type { PipelineJobRequest } from '@/lib/api/schemas'
45
 
46
  type MenuItem = {
47
  label: string
 
56
  triggerTestId?: string
57
  }
58
 
59
+ const openExternal = (url: string) => {
60
+ window.open(url, '_blank', 'noopener,noreferrer')
61
+ }
62
+
63
  export function MenuBar() {
64
  const { t } = useTranslation()
65
+ const { send } = useProcessing()
66
+
67
+ const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
68
+ const { selectedModel, selectedLanguage, renderEffect, renderStroke } =
69
+ useEditorUiStore.getState()
70
+ const { fontFamily, apiKeys } = usePreferencesStore.getState()
71
+ return {
72
+ documentId,
73
+ llmModelId: selectedModel,
74
+ llmApiKey: selectedModel ? apiKeys[selectedModel.split(':')[0]] : undefined,
75
+ language: selectedLanguage,
76
+ shaderEffect: renderEffect,
77
+ shaderStroke: renderStroke,
78
+ fontFamily,
79
+ }
80
+ }
81
+
82
+ const requireDocumentId = () => {
83
+ const id = useEditorUiStore.getState().currentDocumentId
84
+ if (!id) throw new Error('No current document selected')
85
+ return id
86
+ }
87
 
88
  const fileMenuItems: MenuItem[] = [
89
  {
90
  label: t('menu.openFile'),
91
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }),
92
  testId: 'menu-file-open',
93
  },
94
  {
95
  label: t('menu.addFile'),
96
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'files' }),
97
  testId: 'menu-file-add',
98
  },
99
  {
100
  label: t('menu.openFolder'),
101
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }),
102
  testId: 'menu-file-open-folder',
103
  },
104
  {
105
  label: t('menu.addFolder'),
106
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }),
107
  testId: 'menu-file-add-folder',
108
  },
109
  {
110
  label: t('menu.export'),
111
+ onSelect: () => send({ type: 'START_EXPORT', documentId: requireDocumentId(), format: 'webp', params: { layer: 'rendered' } }),
112
  testId: 'menu-file-export',
113
  },
114
  {
115
  label: t('menu.exportPsd'),
116
+ onSelect: () => send({ type: 'START_EXPORT', documentId: requireDocumentId(), format: 'psd' }),
117
  testId: 'menu-file-export-psd',
118
  },
119
  {
120
  label: t('menu.exportAllInpainted'),
121
+ onSelect: () => send({ type: 'START_BATCH_EXPORT', layer: 'inpainted' }),
122
  testId: 'menu-file-export-all-inpainted',
123
  },
124
  {
125
  label: t('menu.exportAllRendered'),
126
+ onSelect: () => send({ type: 'START_BATCH_EXPORT', layer: 'rendered' }),
127
  testId: 'menu-file-export-all-rendered',
128
  },
129
  ]
 
142
  items: [
143
  {
144
  label: t('menu.processCurrent'),
145
+ onSelect: () => {
146
+ const documentId = requireDocumentId()
147
+ send({ type: 'START_PIPELINE', request: buildPipelineRequest(documentId) })
148
+ },
149
  testId: 'menu-process-current',
150
  },
151
  {
152
  label: t('menu.redoInpaintRender'),
153
+ onSelect: () => {
154
+ const documentId = requireDocumentId()
155
+ send({ type: 'START_INPAINT', documentId })
156
+ },
157
  testId: 'menu-process-rerender',
158
  },
159
  {
160
  label: t('menu.processAll'),
161
+ onSelect: () => send({ type: 'START_PIPELINE', request: buildPipelineRequest() }),
162
  testId: 'menu-process-all',
163
  },
164
  ],
ui/components/Navigator.tsx CHANGED
@@ -1,43 +1,43 @@
1
  'use client'
2
 
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { useTranslation } from 'react-i18next'
6
- import { useDocumentsCountQuery, useThumbnailQuery } from '@/lib/query/hooks'
 
 
 
7
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
8
  import { Button } from '@/components/ui/button'
9
  import { ScrollArea } from '@/components/ui/scroll-area'
10
- import { flushTextBlockSync } from '@/lib/services/syncQueues'
11
- import { cancelObjectUrlRevoke, revokeObjectUrlLater } from '@/lib/util'
 
 
 
 
 
 
12
 
13
  export function Navigator() {
14
- const { data: totalPagesData = 0 } = useDocumentsCountQuery()
15
- const totalPages = totalPagesData ?? 0
16
- const documentsVersion = useEditorUiStore((state) => state.documentsVersion)
17
- const currentDocumentIndex = useEditorUiStore(
18
- (state) => state.currentDocumentIndex,
19
- )
20
- const setCurrentDocumentIndex = useEditorUiStore(
21
- (state) => state.setCurrentDocumentIndex,
22
  )
23
- const listRef = useRef<HTMLDivElement | null>(null)
24
- const indices = useMemo(
25
- () => Array.from({ length: totalPages }, (_, idx) => idx),
26
- [totalPages],
27
  )
28
- const rowVirtualizer = useVirtualizer({
29
- count: indices.length,
30
- getScrollElement: () => listRef.current,
31
- getItemKey: (index) => indices[index] ?? index,
32
- estimateSize: () => 320,
33
- overscan: 8,
34
- measureElement: (element) => element.getBoundingClientRect().height,
35
- })
36
  const { t } = useTranslation()
37
 
38
- useEffect(() => {
39
- rowVirtualizer.measure()
40
- }, [rowVirtualizer, totalPages, documentsVersion])
 
 
 
41
 
42
  return (
43
  <div
@@ -66,47 +66,34 @@ export function Navigator() {
66
  )}
67
  </div>
68
 
69
- <ScrollArea className='min-h-0 flex-1' viewportRef={listRef}>
70
- <div className='p-2'>
71
- <div
72
- style={{
73
- height: `${rowVirtualizer.getTotalSize()}px`,
74
- width: '100%',
75
- position: 'relative',
76
- }}
77
- >
78
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
79
- const idx = indices[virtualRow.index]
80
- return (
81
- <div
82
- key={virtualRow.key}
83
- data-index={virtualRow.index}
84
- ref={rowVirtualizer.measureElement}
85
- style={{
86
- position: 'absolute',
87
- top: 0,
88
- left: 0,
89
- width: '100%',
90
- transform: `translateY(${virtualRow.start}px)`,
91
- paddingBottom: '6px',
92
- }}
93
- >
94
- <PagePreview
95
- index={idx}
96
- documentsVersion={documentsVersion}
97
- selected={idx === currentDocumentIndex}
98
- onSelect={() => {
99
- void flushTextBlockSync()
100
- .catch(() => {})
101
- .finally(() => {
102
- setCurrentDocumentIndex(idx)
103
- })
104
- }}
105
- />
106
- </div>
107
- )
108
- })}
109
- </div>
110
  </div>
111
  </ScrollArea>
112
  </div>
@@ -115,36 +102,20 @@ export function Navigator() {
115
 
116
  type PagePreviewProps = {
117
  index: number
118
- documentsVersion: number
119
  selected: boolean
120
  onSelect: () => void
121
  }
122
 
123
  function PagePreview({
124
  index,
125
- documentsVersion,
126
  selected,
127
  onSelect,
128
  }: PagePreviewProps) {
129
- const [preview, setPreview] = useState<string>()
130
- const {
131
- data: thumbnailBlob,
132
- isPending: loading,
133
- isError: error,
134
- } = useThumbnailQuery(index, documentsVersion)
135
-
136
- useLayoutEffect(() => {
137
- if (!thumbnailBlob) {
138
- setPreview(undefined)
139
- return
140
- }
141
- const url = URL.createObjectURL(thumbnailBlob)
142
- cancelObjectUrlRevoke(url)
143
- setPreview(url)
144
- return () => {
145
- revokeObjectUrlLater(url)
146
- }
147
- }, [thumbnailBlob])
148
 
149
  return (
150
  <Button
@@ -153,26 +124,22 @@ function PagePreview({
153
  data-testid={`navigator-page-${index}`}
154
  data-page-index={index}
155
  data-selected={selected}
156
- className='bg-card data-[selected=true]:border-primary flex h-auto flex-col gap-0.5 rounded border border-transparent p-1.5 text-left shadow-sm'
157
  >
158
- {loading ? (
159
- <div className='bg-muted aspect-3/4 w-full animate-pulse rounded' />
160
- ) : error ? (
161
- <div className='bg-muted flex aspect-3/4 w-full items-center justify-center rounded'>
162
- <span className='text-muted-foreground text-[10px]'>?</span>
163
- </div>
164
- ) : preview ? (
165
- <img
166
- src={preview}
167
- alt={`Page ${index + 1}`}
168
- style={{ objectFit: 'contain' }}
169
- className='aspect-3/4 w-full rounded object-cover'
170
- />
171
- ) : (
172
- <div className='bg-muted aspect-3/4 w-full rounded' />
173
- )}
174
- <div className='text-muted-foreground flex flex-1 items-center text-xs'>
175
- <div className='text-foreground mx-auto flex text-center font-semibold'>
176
  {index + 1}
177
  </div>
178
  </div>
 
1
  'use client'
2
 
3
+ import { useRef } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { useTranslation } from 'react-i18next'
6
+ import {
7
+ useListDocuments,
8
+ getGetDocumentThumbnailUrl,
9
+ } from '@/lib/api/documents/documents'
10
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
11
  import { Button } from '@/components/ui/button'
12
  import { ScrollArea } from '@/components/ui/scroll-area'
13
+
14
+ const THUMBNAIL_DPR = typeof window !== 'undefined'
15
+ ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3)
16
+ : 2
17
+
18
+ // Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding
19
+ const ROW_HEIGHT = 230
20
+ const OVERSCAN = 5
21
 
22
  export function Navigator() {
23
+ const { data: documents = [] } = useListDocuments()
24
+ const totalPages = documents.length
25
+ const currentDocumentId = useEditorUiStore(
26
+ (state) => state.currentDocumentId,
 
 
 
 
27
  )
28
+ const setCurrentDocumentId = useEditorUiStore(
29
+ (state) => state.setCurrentDocumentId,
 
 
30
  )
31
+ const currentDocumentIndex = documents.findIndex((d) => d.id === currentDocumentId)
32
+ const viewportRef = useRef<HTMLDivElement | null>(null)
 
 
 
 
 
 
33
  const { t } = useTranslation()
34
 
35
+ const virtualizer = useVirtualizer({
36
+ count: totalPages,
37
+ getScrollElement: () => viewportRef.current,
38
+ estimateSize: () => ROW_HEIGHT,
39
+ overscan: OVERSCAN,
40
+ })
41
 
42
  return (
43
  <div
 
66
  )}
67
  </div>
68
 
69
+ <ScrollArea className='min-h-0 flex-1' viewportRef={viewportRef}>
70
+ <div
71
+ className='relative w-full'
72
+ style={{ height: virtualizer.getTotalSize() }}
73
+ >
74
+ {virtualizer.getVirtualItems().map((virtualRow) => {
75
+ const doc = documents[virtualRow.index]
76
+ return (
77
+ <div
78
+ key={doc?.id ?? virtualRow.index}
79
+ className='absolute left-0 w-full px-1.5 pb-1'
80
+ style={{
81
+ height: ROW_HEIGHT,
82
+ top: 0,
83
+ transform: `translateY(${virtualRow.start}px)`,
84
+ }}
85
+ >
86
+ <PagePreview
87
+ index={virtualRow.index}
88
+ documentId={doc?.id}
89
+ selected={doc?.id === currentDocumentId}
90
+ onSelect={() =>
91
+ doc && setCurrentDocumentId(doc.id)
92
+ }
93
+ />
94
+ </div>
95
+ )
96
+ })}
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
  </ScrollArea>
99
  </div>
 
102
 
103
  type PagePreviewProps = {
104
  index: number
105
+ documentId?: string
106
  selected: boolean
107
  onSelect: () => void
108
  }
109
 
110
  function PagePreview({
111
  index,
112
+ documentId,
113
  selected,
114
  onSelect,
115
  }: PagePreviewProps) {
116
+ const src = documentId
117
+ ? getGetDocumentThumbnailUrl(documentId, { size: 200 * THUMBNAIL_DPR })
118
+ : undefined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  return (
121
  <Button
 
124
  data-testid={`navigator-page-${index}`}
125
  data-page-index={index}
126
  data-selected={selected}
127
+ className='bg-card data-[selected=true]:border-primary flex h-full w-full flex-col gap-0.5 rounded border border-transparent p-1.5 text-left shadow-sm'
128
  >
129
+ <div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded'>
130
+ {src ? (
131
+ <img
132
+ src={src}
133
+ alt={`Page ${index + 1}`}
134
+ loading='lazy'
135
+ className='max-h-full max-w-full rounded object-contain'
136
+ />
137
+ ) : (
138
+ <div className='bg-muted h-full w-full rounded' />
139
+ )}
140
+ </div>
141
+ <div className='text-muted-foreground flex shrink-0 items-center text-xs'>
142
+ <div className='text-foreground mx-auto font-semibold'>
 
 
 
 
143
  {index + 1}
144
  </div>
145
  </div>
ui/components/canvas/CanvasToolbar.tsx CHANGED
@@ -1,7 +1,8 @@
1
  'use client'
2
 
3
- import { useEffect, useMemo, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
 
5
  import { motion } from 'motion/react'
6
  import {
7
  ScanIcon,
@@ -25,17 +26,46 @@ import {
25
  PopoverContent,
26
  PopoverTrigger,
27
  } from '@/components/ui/popover'
28
- import { useLlmUiStore } from '@/lib/stores/llmUiStore'
29
  import {
30
- useLlmModelsQuery,
31
- useLlmReadyQuery,
32
- LOCAL_LLM_PRESET_LABELS,
33
- parsePresetFromModelId,
34
- } from '@/lib/query/hooks'
35
- import { useDocumentMutations, useLlmMutations } from '@/lib/query/mutations'
36
- import { useOperationStore } from '@/lib/stores/operationStore'
 
 
 
 
 
 
 
37
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
38
  import { getProviderDisplayName } from '@/lib/providers'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  export function CanvasToolbar() {
41
  return (
@@ -48,26 +78,34 @@ export function CanvasToolbar() {
48
  }
49
 
50
  function WorkflowButtons() {
51
- const { inpaint, detect, ocr, render } = useDocumentMutations()
52
- const { llmGenerate } = useLlmMutations()
53
- const { data: llmReady = false } = useLlmReadyQuery()
 
54
  const [generating, setGenerating] = useState(false)
55
  const { t } = useTranslation()
56
- const operation = useOperationStore((state) => state.operation)
57
 
58
- const isDetecting =
59
- operation?.type === 'process-current' && operation?.step === 'detect'
60
- const isOcr =
61
- operation?.type === 'process-current' && operation?.step === 'ocr'
62
- const isInpainting =
63
- operation?.type === 'process-current' && operation?.step === 'inpaint'
64
- const isRendering =
65
- operation?.type === 'process-current' && operation?.step === 'render'
 
 
66
 
67
  const handleTranslate = async () => {
 
 
 
68
  setGenerating(true)
69
  try {
70
- await llmGenerate(null)
 
 
 
71
  } catch (error) {
72
  console.error(error)
73
  } finally {
@@ -80,9 +118,9 @@ function WorkflowButtons() {
80
  <Button
81
  variant='ghost'
82
  size='xs'
83
- onClick={detect}
84
  data-testid='toolbar-detect'
85
- disabled={isDetecting}
86
  >
87
  {isDetecting ? (
88
  <LoaderCircleIcon className='size-4 animate-spin' />
@@ -97,9 +135,9 @@ function WorkflowButtons() {
97
  <Button
98
  variant='ghost'
99
  size='xs'
100
- onClick={ocr}
101
  data-testid='toolbar-ocr'
102
- disabled={isOcr}
103
  >
104
  {isOcr ? (
105
  <LoaderCircleIcon className='size-4 animate-spin' />
@@ -131,9 +169,9 @@ function WorkflowButtons() {
131
  <Button
132
  variant='ghost'
133
  size='xs'
134
- onClick={inpaint}
135
  data-testid='toolbar-inpaint'
136
- disabled={isInpainting}
137
  >
138
  {isInpainting ? (
139
  <LoaderCircleIcon className='size-4 animate-spin' />
@@ -148,9 +186,14 @@ function WorkflowButtons() {
148
  <Button
149
  variant='ghost'
150
  size='xs'
151
- onClick={render}
 
 
 
 
 
152
  data-testid='toolbar-render'
153
- disabled={isRendering}
154
  >
155
  {isRendering ? (
156
  <LoaderCircleIcon className='size-4 animate-spin' />
@@ -165,15 +208,14 @@ function WorkflowButtons() {
165
 
166
  function LlmStatusPopover() {
167
  const { data: llmModels = [] } = useLlmModelsQuery()
168
- const llmSelectedModel = useLlmUiStore((state) => state.selectedModel)
169
- const llmSelectedLanguage = useLlmUiStore((state) => state.selectedLanguage)
170
- const llmLoading = useLlmUiStore((state) => state.loading)
171
- const { data: llmReady = false } = useLlmReadyQuery()
172
- const { llmSetSelectedModel, llmSetSelectedLanguage, llmToggleLoadUnload } =
173
- useLlmMutations()
174
  const { t } = useTranslation()
175
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
176
- const localLlm = usePreferencesStore((state) => state.localLlm)
177
 
178
  const selectedModelInfo = useMemo(
179
  () => llmModels.find((m) => m.id === llmSelectedModel),
@@ -192,6 +234,56 @@ function LlmStatusPopover() {
192
  [selectedModelInfo],
193
  )
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  useEffect(() => {
196
  if (llmModels.length === 0) return
197
  const hasCurrent = llmModels.some((model) => model.id === llmSelectedModel)
@@ -203,18 +295,17 @@ function LlmStatusPopover() {
203
  llmSelectedLanguage && languages.includes(llmSelectedLanguage)
204
  ? llmSelectedLanguage
205
  : languages[0]
206
- const currentState = useLlmUiStore.getState()
207
  if (
208
  currentState.selectedModel === nextModel &&
209
  currentState.selectedLanguage === nextLanguage
210
  ) {
211
  return
212
  }
213
- useLlmUiStore.setState((state) => ({
214
  selectedModel: nextModel,
215
  selectedLanguage: nextLanguage,
216
- loading: state.loading,
217
- }))
218
  }, [llmModels, llmSelectedLanguage, llmSelectedModel])
219
 
220
  return (
@@ -242,11 +333,6 @@ function LlmStatusPopover() {
242
  }
243
  />
244
  LLM
245
- {llmReady && selectedModelInfo?.source === 'openai-compatible' && (
246
- <span className='max-w-[80px] truncate text-[10px] opacity-80'>
247
- {selectedModelInfo.id.split(':')[1] ?? selectedModelInfo.id}
248
- </span>
249
- )}
250
  </button>
251
  </PopoverTrigger>
252
  <PopoverContent align='end' className='w-72' data-testid='llm-popover'>
@@ -255,7 +341,7 @@ function LlmStatusPopover() {
255
  {t('panels.llm')}
256
  </p>
257
 
258
- <Select value={llmSelectedModel} onValueChange={llmSetSelectedModel}>
259
  <SelectTrigger data-testid='llm-model-select' className='w-full'>
260
  <SelectValue placeholder={t('llm.selectPlaceholder')} />
261
  </SelectTrigger>
@@ -267,37 +353,14 @@ function LlmStatusPopover() {
267
  data-testid={`llm-model-option-${index}`}
268
  >
269
  <span className='flex items-center gap-2'>
270
- {model.source === 'openai-compatible' ? (
271
- (() => {
272
- const preset = parsePresetFromModelId(model.id)
273
- const isTeal =
274
- preset === 'preset1' || preset === 'preset2'
275
- return (
276
- <span
277
- className={`rounded px-1 py-0.5 text-[10px] leading-none font-semibold uppercase ${
278
- isTeal
279
- ? 'bg-teal-500/10 text-teal-600 dark:text-teal-400'
280
- : 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
281
- }`}
282
- >
283
- {preset
284
- ? (LOCAL_LLM_PRESET_LABELS[preset] ?? preset)
285
- : 'OpenAI-like'}
286
- </span>
287
- )
288
- })()
289
- ) : model.source !== 'local' ? (
290
  <span className='bg-primary/10 text-primary rounded px-1 py-0.5 text-[10px] leading-none font-semibold uppercase'>
291
  {getProviderDisplayName(model.source)}
292
  </span>
293
  ) : null}
294
- {/* Display model name: strip "openai-compatible:preset:" prefix */}
295
- {model.source === 'openai-compatible' &&
296
- model.id.split(':').length >= 3
297
- ? model.id.split(':').slice(2).join(':')
298
- : model.id.includes(':')
299
- ? model.id.split(':')[1]
300
- : model.id}
301
  </span>
302
  </SelectItem>
303
  ))}
@@ -313,65 +376,10 @@ function LlmStatusPopover() {
313
  </p>
314
  )}
315
 
316
- {/* Loaded model info card */}
317
- {llmReady &&
318
- selectedModelInfo?.source === 'openai-compatible' &&
319
- (() => {
320
- const infoPreset = parsePresetFromModelId(selectedModelInfo.id)
321
- const isTeal =
322
- infoPreset === 'preset1' || infoPreset === 'preset2'
323
- const presetLabel = infoPreset
324
- ? (LOCAL_LLM_PRESET_LABELS[infoPreset] ?? infoPreset)
325
- : 'OpenAI-like'
326
- const presetCfg = infoPreset
327
- ? localLlm.presets[infoPreset]
328
- : undefined
329
- const modelName =
330
- selectedModelInfo.id.split(':').length >= 3
331
- ? selectedModelInfo.id.split(':').slice(2).join(':')
332
- : (selectedModelInfo.id.split(':')[1] ?? selectedModelInfo.id)
333
- return (
334
- <div
335
- className={`rounded-md px-2.5 py-2 text-xs ${
336
- isTeal
337
- ? 'border border-teal-500/20 bg-teal-500/5'
338
- : 'border border-emerald-500/20 bg-emerald-500/5'
339
- }`}
340
- >
341
- <div className='flex items-center gap-1.5'>
342
- <span
343
- className={`size-1.5 rounded-full ${
344
- isTeal ? 'bg-teal-500' : 'bg-emerald-500'
345
- }`}
346
- />
347
- <span
348
- className={`font-medium ${
349
- isTeal
350
- ? 'text-teal-700 dark:text-teal-400'
351
- : 'text-emerald-700 dark:text-emerald-400'
352
- }`}
353
- >
354
- {t('llm.localModelActive')}
355
- </span>
356
- </div>
357
- <p className='text-muted-foreground mt-1'>
358
- {t('llm.localModelName', { name: modelName })}
359
- </p>
360
- <p className='text-muted-foreground mt-0.5'>
361
- {presetLabel}
362
- {presetCfg?.temperature != null &&
363
- ` · temp ${presetCfg.temperature}`}
364
- {presetCfg?.maxTokens != null &&
365
- ` · ${presetCfg.maxTokens} tokens`}
366
- </p>
367
- </div>
368
- )
369
- })()}
370
-
371
  {activeLanguages.length > 0 && (
372
  <Select
373
  value={llmSelectedLanguage ?? activeLanguages[0]}
374
- onValueChange={llmSetSelectedLanguage}
375
  >
376
  <SelectTrigger
377
  data-testid='llm-language-select'
@@ -399,7 +407,7 @@ function LlmStatusPopover() {
399
  data-llm-loading={llmLoading ? 'true' : 'false'}
400
  variant='outline'
401
  size='sm'
402
- onClick={llmToggleLoadUnload}
403
  disabled={!llmSelectedModel || llmLoading}
404
  className='w-full gap-1.5 text-xs'
405
  >
 
1
  'use client'
2
 
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
6
  import { motion } from 'motion/react'
7
  import {
8
  ScanIcon,
 
26
  PopoverContent,
27
  PopoverTrigger,
28
  } from '@/components/ui/popover'
29
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
30
  import {
31
+ useGetLlm,
32
+ getListLlmModelsQueryKey,
33
+ listLlmModels,
34
+ unloadLlm,
35
+ } from '@/lib/api/llm/llm'
36
+ import {
37
+ translateDocument,
38
+ } from '@/lib/api/processing/processing'
39
+ import {
40
+ getGetDocumentQueryKey,
41
+ getListDocumentsQueryKey,
42
+ } from '@/lib/api/documents/documents'
43
+ import type { LlmModelInfo } from '@/lib/api/schemas'
44
+ import { useProcessing } from '@/lib/machines'
45
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
46
  import { getProviderDisplayName } from '@/lib/providers'
47
+ import i18n from '@/lib/i18n'
48
+
49
+ function useLlmModelsQuery() {
50
+ const [language, setLanguage] = useState(i18n.language)
51
+ useEffect(() => {
52
+ const handleLanguageChange = (nextLanguage: string) => {
53
+ setLanguage(nextLanguage)
54
+ }
55
+ i18n.on('languageChanged', handleLanguageChange)
56
+ return () => {
57
+ i18n.off('languageChanged', handleLanguageChange)
58
+ }
59
+ }, [])
60
+
61
+ return useQuery<LlmModelInfo[]>({
62
+ queryKey: getListLlmModelsQueryKey({ language: language ?? 'default' }),
63
+ queryFn: async () => {
64
+ return listLlmModels({ language })
65
+ },
66
+ staleTime: 5 * 60 * 1000,
67
+ })
68
+ }
69
 
70
  export function CanvasToolbar() {
71
  return (
 
78
  }
79
 
80
  function WorkflowButtons() {
81
+ const queryClient = useQueryClient()
82
+ const { send, isProcessing, state } = useProcessing()
83
+ const { data: llmState } = useGetLlm()
84
+ const llmReady = llmState?.status === 'ready'
85
  const [generating, setGenerating] = useState(false)
86
  const { t } = useTranslation()
 
87
 
88
+ const isDetecting = state.matches('detecting')
89
+ const isOcr = state.matches('recognizing')
90
+ const isInpainting = state.matches('inpainting')
91
+ const isRendering = state.matches('rendering')
92
+
93
+ const requireDocumentId = () => {
94
+ const id = useEditorUiStore.getState().currentDocumentId
95
+ if (!id) throw new Error('No current document selected')
96
+ return id
97
+ }
98
 
99
  const handleTranslate = async () => {
100
+ const documentId = useEditorUiStore.getState().currentDocumentId
101
+ if (!documentId) return
102
+ const selectedLanguage = useEditorUiStore.getState().selectedLanguage
103
  setGenerating(true)
104
  try {
105
+ await translateDocument(documentId, { language: selectedLanguage })
106
+ await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) })
107
+ await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() })
108
+ useEditorUiStore.getState().setShowTextBlocksOverlay(true)
109
  } catch (error) {
110
  console.error(error)
111
  } finally {
 
118
  <Button
119
  variant='ghost'
120
  size='xs'
121
+ onClick={() => send({ type: 'START_DETECT', documentId: requireDocumentId() })}
122
  data-testid='toolbar-detect'
123
+ disabled={isDetecting || isProcessing}
124
  >
125
  {isDetecting ? (
126
  <LoaderCircleIcon className='size-4 animate-spin' />
 
135
  <Button
136
  variant='ghost'
137
  size='xs'
138
+ onClick={() => send({ type: 'START_RECOGNIZE', documentId: requireDocumentId() })}
139
  data-testid='toolbar-ocr'
140
+ disabled={isOcr || isProcessing}
141
  >
142
  {isOcr ? (
143
  <LoaderCircleIcon className='size-4 animate-spin' />
 
169
  <Button
170
  variant='ghost'
171
  size='xs'
172
+ onClick={() => send({ type: 'START_INPAINT', documentId: requireDocumentId() })}
173
  data-testid='toolbar-inpaint'
174
+ disabled={isInpainting || isProcessing}
175
  >
176
  {isInpainting ? (
177
  <LoaderCircleIcon className='size-4 animate-spin' />
 
186
  <Button
187
  variant='ghost'
188
  size='xs'
189
+ onClick={() => {
190
+ const documentId = requireDocumentId()
191
+ const { renderEffect, renderStroke } = useEditorUiStore.getState()
192
+ const { fontFamily } = usePreferencesStore.getState()
193
+ send({ type: 'START_RENDER', documentId, options: { shaderEffect: renderEffect, shaderStroke: renderStroke, fontFamily } })
194
+ }}
195
  data-testid='toolbar-render'
196
+ disabled={isRendering || isProcessing}
197
  >
198
  {isRendering ? (
199
  <LoaderCircleIcon className='size-4 animate-spin' />
 
208
 
209
  function LlmStatusPopover() {
210
  const { data: llmModels = [] } = useLlmModelsQuery()
211
+ const llmSelectedModel = useEditorUiStore((state) => state.selectedModel)
212
+ const llmSelectedLanguage = useEditorUiStore((state) => state.selectedLanguage)
213
+ const { data: llmState } = useGetLlm()
214
+ const llmReady = llmState?.status === 'ready'
215
+ const llmLoading = llmState?.status === 'loading'
216
+ const { send } = useProcessing()
217
  const { t } = useTranslation()
218
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
 
219
 
220
  const selectedModelInfo = useMemo(
221
  () => llmModels.find((m) => m.id === llmSelectedModel),
 
234
  [selectedModelInfo],
235
  )
236
 
237
+ const handleSetSelectedModel = useCallback(
238
+ async (id: string) => {
239
+ await unloadLlm()
240
+ const models = llmModels
241
+ const languages = models.find((m) => m.id === id)?.languages ?? []
242
+ const nextLanguage =
243
+ llmSelectedLanguage && languages.includes(llmSelectedLanguage)
244
+ ? llmSelectedLanguage
245
+ : languages[0]
246
+ useEditorUiStore.setState({
247
+ selectedModel: id,
248
+ selectedLanguage: nextLanguage,
249
+ })
250
+ },
251
+ [llmModels, llmSelectedLanguage],
252
+ )
253
+
254
+ const handleSetSelectedLanguage = useCallback(
255
+ (language: string) => {
256
+ const languages = selectedModelInfo?.languages ?? []
257
+ if (!languages.includes(language)) return
258
+ useEditorUiStore.setState({ selectedLanguage: language })
259
+ },
260
+ [selectedModelInfo],
261
+ )
262
+
263
+ const handleToggleLoadUnload = useCallback(async () => {
264
+ const selectedModel = useEditorUiStore.getState().selectedModel
265
+ if (!selectedModel) return
266
+
267
+ if (llmReady) {
268
+ await unloadLlm()
269
+ return
270
+ }
271
+
272
+ const modelInfo = llmModels.find((m) => m.id === selectedModel)
273
+ const apiKey =
274
+ modelInfo && modelInfo.source !== 'local'
275
+ ? apiKeys[modelInfo.source]
276
+ : undefined
277
+
278
+ send({
279
+ type: 'START_LLM_LOAD',
280
+ request: {
281
+ id: selectedModel,
282
+ apiKey,
283
+ },
284
+ })
285
+ }, [llmReady, llmModels, apiKeys, send])
286
+
287
  useEffect(() => {
288
  if (llmModels.length === 0) return
289
  const hasCurrent = llmModels.some((model) => model.id === llmSelectedModel)
 
295
  llmSelectedLanguage && languages.includes(llmSelectedLanguage)
296
  ? llmSelectedLanguage
297
  : languages[0]
298
+ const currentState = useEditorUiStore.getState()
299
  if (
300
  currentState.selectedModel === nextModel &&
301
  currentState.selectedLanguage === nextLanguage
302
  ) {
303
  return
304
  }
305
+ useEditorUiStore.setState({
306
  selectedModel: nextModel,
307
  selectedLanguage: nextLanguage,
308
+ })
 
309
  }, [llmModels, llmSelectedLanguage, llmSelectedModel])
310
 
311
  return (
 
333
  }
334
  />
335
  LLM
 
 
 
 
 
336
  </button>
337
  </PopoverTrigger>
338
  <PopoverContent align='end' className='w-72' data-testid='llm-popover'>
 
341
  {t('panels.llm')}
342
  </p>
343
 
344
+ <Select value={llmSelectedModel} onValueChange={handleSetSelectedModel}>
345
  <SelectTrigger data-testid='llm-model-select' className='w-full'>
346
  <SelectValue placeholder={t('llm.selectPlaceholder')} />
347
  </SelectTrigger>
 
353
  data-testid={`llm-model-option-${index}`}
354
  >
355
  <span className='flex items-center gap-2'>
356
+ {model.source !== 'local' ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  <span className='bg-primary/10 text-primary rounded px-1 py-0.5 text-[10px] leading-none font-semibold uppercase'>
358
  {getProviderDisplayName(model.source)}
359
  </span>
360
  ) : null}
361
+ {model.id.includes(':')
362
+ ? model.id.split(':')[1]
363
+ : model.id}
 
 
 
 
364
  </span>
365
  </SelectItem>
366
  ))}
 
376
  </p>
377
  )}
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  {activeLanguages.length > 0 && (
380
  <Select
381
  value={llmSelectedLanguage ?? activeLanguages[0]}
382
+ onValueChange={handleSetSelectedLanguage}
383
  >
384
  <SelectTrigger
385
  data-testid='llm-language-select'
 
407
  data-llm-loading={llmLoading ? 'true' : 'false'}
408
  variant='outline'
409
  size='sm'
410
+ onClick={handleToggleLoadUnload}
411
  disabled={!llmSelectedModel || llmLoading}
412
  className='w-full gap-1.5 text-xs'
413
  >
ui/components/canvas/Workspace.tsx CHANGED
@@ -15,6 +15,7 @@ import { listen } from '@/lib/backend'
15
  import { Image } from '@/components/Image'
16
  import {
17
  setCanvasViewport,
 
18
  fitCanvasToViewport,
19
  } from '@/components/canvas/canvasViewport'
20
  import { ToolRail } from '@/components/canvas/ToolRail'
@@ -61,6 +62,13 @@ export function Workspace() {
61
  appendBlock,
62
  removeBlock,
63
  } = useTextBlocks()
 
 
 
 
 
 
 
64
  const viewportRef = useRef<HTMLDivElement | null>(null)
65
  const { setScale: applyScale } = useCanvasZoom()
66
  const scaleRatio = scale / 100
 
15
  import { Image } from '@/components/Image'
16
  import {
17
  setCanvasViewport,
18
+ setCanvasDocumentSize,
19
  fitCanvasToViewport,
20
  } from '@/components/canvas/canvasViewport'
21
  import { ToolRail } from '@/components/canvas/ToolRail'
 
62
  appendBlock,
63
  removeBlock,
64
  } = useTextBlocks()
65
+
66
+ useEffect(() => {
67
+ if (currentDocument) {
68
+ setCanvasDocumentSize(currentDocument.width, currentDocument.height)
69
+ }
70
+ }, [currentDocument?.width, currentDocument?.height])
71
+
72
  const viewportRef = useRef<HTMLDivElement | null>(null)
73
  const { setScale: applyScale } = useCanvasZoom()
74
  const scaleRatio = scale / 100
ui/components/canvas/canvasViewport.ts CHANGED
@@ -1,35 +1,31 @@
1
  'use client'
2
 
3
- import { queryKeys } from '@/lib/query/keys'
4
- import { getQueryClient } from '@/lib/query/client'
5
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
6
- import type { Document } from '@/types'
7
 
8
  const canvasViewportRef: { current: HTMLDivElement | null } = { current: null }
 
9
 
10
  export function setCanvasViewport(element: HTMLDivElement | null) {
11
  canvasViewportRef.current = element
12
  }
13
 
 
 
 
 
14
  export function fitCanvasToViewport() {
15
- const { setScale, setAutoFitEnabled, currentDocumentIndex } =
16
- useEditorUiStore.getState()
17
- const doc = getQueryClient().getQueryData<Document>(
18
- queryKeys.documents.current(currentDocumentIndex),
19
- )
20
  const viewport = canvasViewportRef.current
21
- if (!doc || !viewport) return
22
  const rect = viewport.getBoundingClientRect()
23
- if (!rect.width || !rect.height || !doc.width || !doc.height) return
24
- const scaleW = ((rect.width - 10) / doc.width) * 100 // leave 10px for margin
25
- const scaleH = ((rect.height - 10) / doc.height) * 100 // leave 10px for margin
26
  const fit = Math.max(10, Math.min(100, Math.min(scaleW, scaleH)))
27
- setAutoFitEnabled(true)
28
- setScale(fit)
29
  }
30
 
31
  export function resetCanvasScale() {
32
- const { setScale, setAutoFitEnabled } = useEditorUiStore.getState()
33
- setAutoFitEnabled(false)
34
- setScale(100)
35
  }
 
1
  'use client'
2
 
 
 
3
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
 
4
 
5
  const canvasViewportRef: { current: HTMLDivElement | null } = { current: null }
6
+ let docSize: { width: number; height: number } | null = null
7
 
8
  export function setCanvasViewport(element: HTMLDivElement | null) {
9
  canvasViewportRef.current = element
10
  }
11
 
12
+ export function setCanvasDocumentSize(width: number, height: number) {
13
+ docSize = { width, height }
14
+ }
15
+
16
  export function fitCanvasToViewport() {
 
 
 
 
 
17
  const viewport = canvasViewportRef.current
18
+ if (!docSize || !viewport) return
19
  const rect = viewport.getBoundingClientRect()
20
+ if (!rect.width || !rect.height || !docSize.width || !docSize.height) return
21
+ const scaleW = ((rect.width - 10) / docSize.width) * 100
22
+ const scaleH = ((rect.height - 10) / docSize.height) * 100
23
  const fit = Math.max(10, Math.min(100, Math.min(scaleW, scaleH)))
24
+ useEditorUiStore.getState().setAutoFitEnabled(true)
25
+ useEditorUiStore.getState().setScale(fit)
26
  }
27
 
28
  export function resetCanvasScale() {
29
+ useEditorUiStore.getState().setAutoFitEnabled(false)
30
+ useEditorUiStore.getState().setScale(100)
 
31
  }
ui/components/panels/LayersPanel.tsx CHANGED
@@ -14,7 +14,7 @@ import {
14
  import { cn } from '@/lib/utils'
15
  import { Button } from '@/components/ui/button'
16
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
17
- import { useCurrentDocumentState } from '@/lib/query/hooks'
18
 
19
  type Layer = {
20
  id: string
@@ -27,7 +27,7 @@ type Layer = {
27
  }
28
 
29
  export function LayersPanel() {
30
- const { currentDocument } = useCurrentDocumentState()
31
  const showInpaintedImage = useEditorUiStore(
32
  (state) => state.showInpaintedImage,
33
  )
 
14
  import { cn } from '@/lib/utils'
15
  import { Button } from '@/components/ui/button'
16
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
17
+ import { useCurrentDocument } from '@/hooks/useTextBlocks'
18
 
19
  type Layer = {
20
  id: string
 
27
  }
28
 
29
  export function LayersPanel() {
30
+ const currentDocument = useCurrentDocument()
31
  const showInpaintedImage = useEditorUiStore(
32
  (state) => state.showInpaintedImage,
33
  )
ui/components/panels/RenderControlsPanel.tsx CHANGED
@@ -20,7 +20,7 @@ import {
20
  TextAlign,
21
  TextStyle,
22
  } from '@/types'
23
- import type { FontFaceInfo } from '@/lib/protocol'
24
  import { Button } from '@/components/ui/button'
25
  import { ColorPicker } from '@/components/ui/color-picker'
26
  import { Input } from '@/components/ui/input'
@@ -38,8 +38,7 @@ import {
38
  } from '@/components/ui/select'
39
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
40
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
41
- import { useFontsQuery } from '@/lib/query/hooks'
42
- import { useTextBlockMutations } from '@/lib/query/mutations'
43
  import { cn } from '@/lib/utils'
44
 
45
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
@@ -183,11 +182,10 @@ export function RenderControlsPanel() {
183
  const renderStroke = useEditorUiStore((state) => state.renderStroke)
184
  const setRenderEffect = useEditorUiStore((state) => state.setRenderEffect)
185
  const setRenderStroke = useEditorUiStore((state) => state.setRenderStroke)
186
- const { updateTextBlocks } = useTextBlockMutations()
187
- const { data: availableFonts = [] } = useFontsQuery()
188
  const fontFamily = usePreferencesStore((state) => state.fontFamily)
189
  const setFontFamily = usePreferencesStore((state) => state.setFontFamily)
190
- const { textBlocks, selectedBlockIndex, replaceBlock } = useTextBlocks()
191
  const { t } = useTranslation()
192
  const selectedBlock =
193
  selectedBlockIndex !== undefined
 
20
  TextAlign,
21
  TextStyle,
22
  } from '@/types'
23
+ import type { FontFaceInfo } from '@/lib/api/schemas'
24
  import { Button } from '@/components/ui/button'
25
  import { ColorPicker } from '@/components/ui/color-picker'
26
  import { Input } from '@/components/ui/input'
 
38
  } from '@/components/ui/select'
39
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
40
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
41
+ import { useListFonts } from '@/lib/api/system/system'
 
42
  import { cn } from '@/lib/utils'
43
 
44
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
 
182
  const renderStroke = useEditorUiStore((state) => state.renderStroke)
183
  const setRenderEffect = useEditorUiStore((state) => state.setRenderEffect)
184
  const setRenderStroke = useEditorUiStore((state) => state.setRenderStroke)
185
+ const { data: availableFonts = [] } = useListFonts()
 
186
  const fontFamily = usePreferencesStore((state) => state.fontFamily)
187
  const setFontFamily = usePreferencesStore((state) => state.setFontFamily)
188
+ const { textBlocks, selectedBlockIndex, replaceBlock, updateTextBlocks } = useTextBlocks()
189
  const { t } = useTranslation()
190
  const selectedBlock =
191
  selectedBlockIndex !== undefined
ui/components/panels/TextBlocksPanel.tsx CHANGED
@@ -2,12 +2,20 @@
2
 
3
  import { useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
 
5
  import { motion } from 'motion/react'
6
  import { TextBlock } from '@/types'
7
  import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
8
  import { useTextBlocks } from '@/hooks/useTextBlocks'
9
- import { useLlmReadyQuery } from '@/lib/query/hooks'
10
- import { useLlmMutations } from '@/lib/query/mutations'
 
 
 
 
 
 
 
11
  import {
12
  Accordion,
13
  AccordionContent,
@@ -24,6 +32,7 @@ import { DraftTextarea } from '@/components/ui/draft-textarea'
24
  import { ScrollArea } from '@/components/ui/scroll-area'
25
 
26
  export function TextBlocksPanel() {
 
27
  const {
28
  document,
29
  textBlocks,
@@ -33,8 +42,8 @@ export function TextBlocksPanel() {
33
  removeBlock,
34
  } = useTextBlocks()
35
  const { t } = useTranslation()
36
- const { llmGenerate } = useLlmMutations()
37
- const { data: llmReady = false } = useLlmReadyQuery()
38
  const [generatingIndex, setGeneratingIndex] = useState<number | null>(null)
39
  const generating = generatingIndex !== null
40
 
@@ -49,10 +58,30 @@ export function TextBlocksPanel() {
49
  const accordionValue =
50
  selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
51
 
 
 
 
 
 
52
  const handleGenerate = async (blockIndex: number) => {
 
 
 
 
53
  setGeneratingIndex(blockIndex)
54
  try {
55
- await llmGenerate(undefined, undefined, blockIndex)
 
 
 
 
 
 
 
 
 
 
 
56
  } catch (error) {
57
  console.error(error)
58
  } finally {
 
2
 
3
  import { useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { useQueryClient } from '@tanstack/react-query'
6
  import { motion } from 'motion/react'
7
  import { TextBlock } from '@/types'
8
  import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
9
  import { useTextBlocks } from '@/hooks/useTextBlocks'
10
+ import { useGetLlm } from '@/lib/api/llm/llm'
11
+ import { translateDocument } from '@/lib/api/processing/processing'
12
+ import { renderDocument } from '@/lib/api/processing/processing'
13
+ import {
14
+ getGetDocumentQueryKey,
15
+ getListDocumentsQueryKey,
16
+ } from '@/lib/api/documents/documents'
17
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
18
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
19
  import {
20
  Accordion,
21
  AccordionContent,
 
32
  import { ScrollArea } from '@/components/ui/scroll-area'
33
 
34
  export function TextBlocksPanel() {
35
+ const queryClient = useQueryClient()
36
  const {
37
  document,
38
  textBlocks,
 
42
  removeBlock,
43
  } = useTextBlocks()
44
  const { t } = useTranslation()
45
+ const { data: llm } = useGetLlm()
46
+ const llmReady = llm?.status === 'ready'
47
  const [generatingIndex, setGeneratingIndex] = useState<number | null>(null)
48
  const generating = generatingIndex !== null
49
 
 
58
  const accordionValue =
59
  selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
60
 
61
+ const invalidateDocument = async (documentId: string) => {
62
+ await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) })
63
+ await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() })
64
+ }
65
+
66
  const handleGenerate = async (blockIndex: number) => {
67
+ const documentId = useEditorUiStore.getState().currentDocumentId
68
+ if (!documentId) return
69
+ const selectedLanguage = useEditorUiStore.getState().selectedLanguage
70
+ const textBlockId = document.textBlocks[blockIndex]?.id
71
  setGeneratingIndex(blockIndex)
72
  try {
73
+ await translateDocument(documentId, { textBlockId, language: selectedLanguage })
74
+ await invalidateDocument(documentId)
75
+ useEditorUiStore.getState().setShowTextBlocksOverlay(true)
76
+ // Re-render the block's sprite
77
+ const { renderEffect, renderStroke } = useEditorUiStore.getState()
78
+ const { fontFamily } = usePreferencesStore.getState()
79
+ await renderDocument(documentId, {
80
+ shaderEffect: renderEffect,
81
+ shaderStroke: renderStroke,
82
+ fontFamily,
83
+ })
84
+ await invalidateDocument(documentId)
85
  } catch (error) {
86
  console.error(error)
87
  } finally {
ui/hooks/useBlockContextMenu.ts CHANGED
@@ -2,11 +2,11 @@
2
 
3
  import { useState } from 'react'
4
  import type React from 'react'
5
- import { Document } from '@/types'
6
  import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
7
 
8
  type BlockContextMenuOptions = {
9
- currentDocument: Document | null
10
  pointerToDocument: PointerToDocumentFn
11
  selectBlock: (index?: number) => void
12
  removeBlock: (index: number) => void
 
2
 
3
  import { useState } from 'react'
4
  import type React from 'react'
5
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
6
  import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
7
 
8
  type BlockContextMenuOptions = {
9
+ currentDocument: MappedDocument | null
10
  pointerToDocument: PointerToDocumentFn
11
  selectBlock: (index?: number) => void
12
  removeBlock: (index: number) => void
ui/hooks/useBlockDrafting.ts CHANGED
@@ -2,7 +2,8 @@
2
 
3
  import { useRef, useState } from 'react'
4
  import { useDrag } from '@use-gesture/react'
5
- import { Document, TextBlock, ToolMode } from '@/types'
 
6
  import type {
7
  PointerToDocumentFn,
8
  DocumentPointer,
@@ -10,7 +11,7 @@ import type {
10
 
11
  type BlockDraftingOptions = {
12
  mode: ToolMode
13
- currentDocument: Document | null
14
  pointerToDocument: PointerToDocumentFn
15
  clearSelection: () => void
16
  onCreateBlock: (block: TextBlock) => void
 
2
 
3
  import { useRef, useState } from 'react'
4
  import { useDrag } from '@use-gesture/react'
5
+ import { TextBlock, ToolMode } from '@/types'
6
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
7
  import type {
8
  PointerToDocumentFn,
9
  DocumentPointer,
 
11
 
12
  type BlockDraftingOptions = {
13
  mode: ToolMode
14
+ currentDocument: MappedDocument | null
15
  pointerToDocument: PointerToDocumentFn
16
  clearSelection: () => void
17
  onCreateBlock: (block: TextBlock) => void
ui/hooks/useBrushLayerDisplay.ts CHANGED
@@ -2,10 +2,10 @@
2
 
3
  import { useEffect, useRef } from 'react'
4
  import { convertToImageBitmap } from '@/lib/util'
5
- import { Document } from '@/types'
6
 
7
  type BrushLayerDisplayOptions = {
8
- currentDocument: Document | null
9
  visible: boolean
10
  }
11
 
 
2
 
3
  import { useEffect, useRef } from 'react'
4
  import { convertToImageBitmap } from '@/lib/util'
5
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
6
 
7
  type BrushLayerDisplayOptions = {
8
+ currentDocument: MappedDocument | null
9
  visible: boolean
10
  }
11
 
ui/hooks/useCanvasDrawing.ts ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type RefObject } from 'react'
4
+ import { useDrag } from '@use-gesture/react'
5
+ import { blobToUint8Array } from '@/lib/util'
6
+ import type { InpaintRegion } from '@/types'
7
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
8
+ import {
9
+ type PointerToDocumentFn,
10
+ type DocumentPointer,
11
+ } from '@/hooks/usePointerToDocument'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ type Bounds = {
18
+ minX: number
19
+ minY: number
20
+ maxX: number
21
+ maxY: number
22
+ }
23
+
24
+ export type CanvasDrawingConfig = {
25
+ getColor: () => string
26
+ blendMode: GlobalCompositeOperation
27
+ getBrushSize: () => number
28
+ onFinalize: (patch: Uint8Array, region: InpaintRegion) => Promise<void>
29
+ /** Called after finalize with the full-canvas PNG and the patch region. */
30
+ onFinalizeFullCanvas?: (
31
+ fullPng: Uint8Array,
32
+ patchRegion: InpaintRegion,
33
+ ) => Promise<void>
34
+ enabled: boolean
35
+ /** Optional second canvas to mirror strokes to. */
36
+ targetCanvasRef?: RefObject<HTMLCanvasElement | null>
37
+ /** When true, clear the drawing canvas after each stroke finalize. */
38
+ clearAfterStroke?: boolean
39
+ /** Called to set up the canvas content when the document changes (e.g. draw existing mask). */
40
+ onCanvasInit?: (ctx: CanvasRenderingContext2D, doc: MappedDocument) => void | Promise<void>
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const clampToDocument = (
48
+ point: DocumentPointer,
49
+ doc?: MappedDocument,
50
+ ): DocumentPointer => {
51
+ if (!doc) return point
52
+ return {
53
+ x: Math.max(0, Math.min(doc.width, point.x)),
54
+ y: Math.max(0, Math.min(doc.height, point.y)),
55
+ }
56
+ }
57
+
58
+ const expandBounds = (
59
+ bounds: Bounds,
60
+ point: DocumentPointer,
61
+ radius: number,
62
+ ): Bounds => ({
63
+ minX: Math.min(bounds.minX, point.x - radius),
64
+ minY: Math.min(bounds.minY, point.y - radius),
65
+ maxX: Math.max(bounds.maxX, point.x + radius),
66
+ maxY: Math.max(bounds.maxY, point.y + radius),
67
+ })
68
+
69
+ const boundsToRegion = (bounds: Bounds, doc: MappedDocument): InpaintRegion => {
70
+ const x0 = Math.max(0, Math.floor(bounds.minX))
71
+ const y0 = Math.max(0, Math.floor(bounds.minY))
72
+ const x1 = Math.min(doc.width, Math.ceil(bounds.maxX))
73
+ const y1 = Math.min(doc.height, Math.ceil(bounds.maxY))
74
+ return { x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0) }
75
+ }
76
+
77
+ const exportCanvasRegion = async (
78
+ canvas: HTMLCanvasElement,
79
+ region: InpaintRegion,
80
+ ): Promise<Uint8Array | null> => {
81
+ if (region.width <= 0 || region.height <= 0) return null
82
+ const tmp = document.createElement('canvas')
83
+ tmp.width = region.width
84
+ tmp.height = region.height
85
+ const ctx = tmp.getContext('2d')
86
+ if (!ctx) return null
87
+ ctx.drawImage(canvas, region.x, region.y, region.width, region.height, 0, 0, region.width, region.height)
88
+ const blob = await new Promise<Blob | null>((r) => tmp.toBlob(r, 'image/png'))
89
+ return blob ? blobToUint8Array(blob) : null
90
+ }
91
+
92
+ const exportFullCanvas = async (
93
+ canvas: HTMLCanvasElement,
94
+ ): Promise<Uint8Array | null> => {
95
+ const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'))
96
+ return blob ? blobToUint8Array(blob) : null
97
+ }
98
+
99
+ const initBounds = (point: DocumentPointer, radius: number): Bounds => ({
100
+ minX: point.x - radius,
101
+ minY: point.y - radius,
102
+ maxX: point.x + radius,
103
+ maxY: point.y + radius,
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Hook
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export function useCanvasDrawing(
111
+ currentDocument: MappedDocument | null,
112
+ pointerToDocument: PointerToDocumentFn,
113
+ config: CanvasDrawingConfig,
114
+ ) {
115
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
116
+ const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
117
+ const drawingRef = useRef(false)
118
+ const lastPointRef = useRef<DocumentPointer | null>(null)
119
+ const boundsRef = useRef<Bounds | null>(null)
120
+
121
+ // Reset drawing state when disabled
122
+ useEffect(() => {
123
+ if (config.enabled) return
124
+ drawingRef.current = false
125
+ lastPointRef.current = null
126
+ boundsRef.current = null
127
+ }, [config.enabled])
128
+
129
+ // Canvas setup and resize
130
+ useEffect(() => {
131
+ const canvas = canvasRef.current
132
+ if (!canvas) return
133
+ const ctx = canvas.getContext('2d')
134
+ ctxRef.current = ctx
135
+
136
+ if (!currentDocument || !config.enabled) {
137
+ canvas.width = 0
138
+ canvas.height = 0
139
+ ctx?.clearRect(0, 0, canvas.width, canvas.height)
140
+ return () => {
141
+ drawingRef.current = false
142
+ lastPointRef.current = null
143
+ boundsRef.current = null
144
+ }
145
+ }
146
+
147
+ const needsResize =
148
+ canvas.width !== currentDocument.width ||
149
+ canvas.height !== currentDocument.height
150
+
151
+ if (needsResize) {
152
+ canvas.width = currentDocument.width
153
+ canvas.height = currentDocument.height
154
+ }
155
+ ctx?.clearRect(0, 0, canvas.width, canvas.height)
156
+
157
+ let cancelled = false
158
+ if (config.onCanvasInit && ctx) {
159
+ const result = config.onCanvasInit(ctx, currentDocument)
160
+ if (result && typeof (result as any).then === 'function') {
161
+ void (result as Promise<void>).catch(console.error)
162
+ }
163
+ }
164
+
165
+ return () => {
166
+ cancelled = true
167
+ drawingRef.current = false
168
+ lastPointRef.current = null
169
+ boundsRef.current = null
170
+ }
171
+ }, [
172
+ currentDocument?.id,
173
+ currentDocument?.width,
174
+ currentDocument?.height,
175
+ config.enabled,
176
+ ])
177
+
178
+ const drawStroke = (from: DocumentPointer, to: DocumentPointer) => {
179
+ const color = config.getColor()
180
+ const brushSize = config.getBrushSize()
181
+
182
+ const stroke = (ctx: CanvasRenderingContext2D) => {
183
+ ctx.save()
184
+ ctx.lineCap = 'round'
185
+ ctx.lineJoin = 'round'
186
+ ctx.lineWidth = brushSize
187
+ ctx.strokeStyle = color
188
+ ctx.fillStyle = color
189
+ ctx.globalCompositeOperation = config.blendMode
190
+ ctx.beginPath()
191
+ ctx.moveTo(from.x, from.y)
192
+ ctx.lineTo(to.x, to.y)
193
+ ctx.stroke()
194
+ ctx.restore()
195
+ }
196
+
197
+ const ctx = ctxRef.current
198
+ if (ctx) stroke(ctx)
199
+
200
+ const targetCtx = config.targetCanvasRef?.current?.getContext('2d')
201
+ if (targetCtx) stroke(targetCtx)
202
+ }
203
+
204
+ const finalizeStroke = () => {
205
+ if (!config.enabled) return
206
+ const strokeBounds = boundsRef.current
207
+ if (!currentDocument || !strokeBounds) return
208
+ const patchRegion = boundsToRegion(strokeBounds, currentDocument)
209
+ boundsRef.current = null
210
+ drawingRef.current = false
211
+ lastPointRef.current = null
212
+
213
+ void (async () => {
214
+ const sourceCanvas = config.targetCanvasRef?.current ?? canvasRef.current
215
+ if (!sourceCanvas) return
216
+ const patchBytes = await exportCanvasRegion(sourceCanvas, patchRegion)
217
+
218
+ if (config.onFinalizeFullCanvas) {
219
+ const fullBytes = await exportFullCanvas(canvasRef.current!)
220
+ if (fullBytes) {
221
+ try {
222
+ await config.onFinalizeFullCanvas(fullBytes, patchRegion)
223
+ } catch (e) {
224
+ console.error(e)
225
+ }
226
+ }
227
+ }
228
+
229
+ if (patchBytes) {
230
+ try {
231
+ await config.onFinalize(patchBytes, patchRegion)
232
+ } catch (e) {
233
+ console.error(e)
234
+ }
235
+ }
236
+
237
+ if (config.clearAfterStroke) {
238
+ const ctx = ctxRef.current
239
+ const canvas = canvasRef.current
240
+ if (ctx && canvas) ctx.clearRect(0, 0, canvas.width, canvas.height)
241
+ }
242
+ })()
243
+ }
244
+
245
+ const bind = useDrag(
246
+ ({ first, last, event, active }) => {
247
+ if (!config.enabled || !currentDocument) return
248
+ const sourceEvent = event as MouseEvent
249
+ const point = pointerToDocument(sourceEvent)
250
+ if (!point) {
251
+ if ((last || !active) && drawingRef.current) finalizeStroke()
252
+ return
253
+ }
254
+ const clamped = clampToDocument(point, currentDocument)
255
+ const brushSize = config.getBrushSize()
256
+ const radius = brushSize / 2
257
+
258
+ if (first) {
259
+ drawingRef.current = true
260
+ lastPointRef.current = clamped
261
+ boundsRef.current = initBounds(clamped, radius)
262
+ drawStroke(clamped, clamped)
263
+ return
264
+ }
265
+
266
+ if (!drawingRef.current) return
267
+ const lastPoint = lastPointRef.current ?? clamped
268
+ drawStroke(lastPoint, clamped)
269
+ lastPointRef.current = clamped
270
+ boundsRef.current = boundsRef.current
271
+ ? expandBounds(boundsRef.current, clamped, radius)
272
+ : initBounds(clamped, radius)
273
+
274
+ if (last || !active) finalizeStroke()
275
+ },
276
+ {
277
+ pointer: { buttons: 1, touch: true },
278
+ preventDefault: true,
279
+ filterTaps: true,
280
+ eventOptions: { passive: false },
281
+ },
282
+ )
283
+
284
+ return { canvasRef, visible: config.enabled, bind }
285
+ }
ui/hooks/useCanvasZoom.ts CHANGED
@@ -1,16 +1,19 @@
1
  'use client'
2
 
3
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
4
- import { useCurrentDocumentState } from '@/lib/query/hooks'
5
 
6
  export function useCanvasZoom() {
7
  const scale = useEditorUiStore((state) => state.scale)
8
  const setScaleRaw = useEditorUiStore((state) => state.setScale)
9
  const setAutoFitEnabled = useEditorUiStore((state) => state.setAutoFitEnabled)
10
- const { currentDocument } = useCurrentDocumentState()
 
 
 
11
 
12
- const summary = currentDocument
13
- ? `${currentDocument.width} x ${currentDocument.height}`
14
  : '--'
15
 
16
  const applyScale = (value: number) => {
 
1
  'use client'
2
 
3
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
4
+ import { useGetDocument } from '@/lib/api/documents/documents'
5
 
6
  export function useCanvasZoom() {
7
  const scale = useEditorUiStore((state) => state.scale)
8
  const setScaleRaw = useEditorUiStore((state) => state.setScale)
9
  const setAutoFitEnabled = useEditorUiStore((state) => state.setAutoFitEnabled)
10
+ const documentId = useEditorUiStore((s) => s.currentDocumentId)
11
+ const { data: document } = useGetDocument(documentId ?? '', {
12
+ query: { enabled: !!documentId },
13
+ })
14
 
15
+ const summary = document
16
+ ? `${document.width} x ${document.height}`
17
  : '--'
18
 
19
  const applyScale = (value: number) => {
ui/hooks/useMaskDrawing.ts CHANGED
@@ -1,87 +1,31 @@
1
  'use client'
2
 
3
- import { useEffect, useRef } from 'react'
4
- import { useDrag } from '@use-gesture/react'
5
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
6
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
7
- import { useMaskMutations } from '@/lib/query/mutations'
8
- import { blobToUint8Array, convertToImageBitmap } from '@/lib/util'
9
- import { Document, InpaintRegion, ToolMode } from '@/types'
10
  import {
11
- PointerToDocumentFn,
12
- type DocumentPointer,
13
- } from '@/hooks/usePointerToDocument'
 
 
 
 
 
 
 
 
 
 
14
 
15
  type MaskDrawingOptions = {
16
  mode: ToolMode
17
- currentDocument: Document | null
18
  pointerToDocument: PointerToDocumentFn
19
  showMask: boolean
20
  enabled: boolean
21
  }
22
 
23
- type Bounds = {
24
- minX: number
25
- minY: number
26
- maxX: number
27
- maxY: number
28
- }
29
-
30
- const clampToDocument = (
31
- point: DocumentPointer,
32
- doc?: Document,
33
- ): DocumentPointer => {
34
- if (!doc) return point
35
- return {
36
- x: Math.max(0, Math.min(doc.width, point.x)),
37
- y: Math.max(0, Math.min(doc.height, point.y)),
38
- }
39
- }
40
-
41
- const expandBounds = (bounds: Bounds, point: DocumentPointer, radius: number) =>
42
- ({
43
- minX: Math.min(bounds.minX, point.x - radius),
44
- minY: Math.min(bounds.minY, point.y - radius),
45
- maxX: Math.max(bounds.maxX, point.x + radius),
46
- maxY: Math.max(bounds.maxY, point.y + radius),
47
- }) satisfies Bounds
48
-
49
- const withMargin = (
50
- bounds: Bounds,
51
- brushSize: number,
52
- doc: Document,
53
- ): InpaintRegion => {
54
- const width = Math.max(brushSize, bounds.maxX - bounds.minX)
55
- const height = Math.max(brushSize, bounds.maxY - bounds.minY)
56
- const margin = Math.min(width * 0.2, 32)
57
-
58
- const x0 = Math.max(0, Math.floor(bounds.minX - margin))
59
- const y0 = Math.max(0, Math.floor(bounds.minY - margin))
60
- const x1 = Math.min(doc.width, Math.ceil(bounds.maxX + margin))
61
- const y1 = Math.min(doc.height, Math.ceil(bounds.maxY + margin))
62
-
63
- return {
64
- x: x0,
65
- y: y0,
66
- width: Math.max(1, x1 - x0),
67
- height: Math.max(1, y1 - y0),
68
- }
69
- }
70
-
71
- const boundsToRegion = (bounds: Bounds, doc: Document): InpaintRegion => {
72
- const x0 = Math.max(0, Math.floor(bounds.minX))
73
- const y0 = Math.max(0, Math.floor(bounds.minY))
74
- const x1 = Math.min(doc.width, Math.ceil(bounds.maxX))
75
- const y1 = Math.min(doc.height, Math.ceil(bounds.maxY))
76
-
77
- return {
78
- x: x0,
79
- y: y0,
80
- width: Math.max(1, x1 - x0),
81
- height: Math.max(1, y1 - y0),
82
- }
83
- }
84
-
85
  export function useMaskDrawing({
86
  mode,
87
  currentDocument,
@@ -89,254 +33,86 @@ export function useMaskDrawing({
89
  showMask,
90
  enabled,
91
  }: MaskDrawingOptions) {
92
- const {
93
- brushConfig: { size: brushSize },
94
- } = usePreferencesStore()
95
- const { updateMask, inpaintPartial } = useMaskMutations()
96
- const currentDocumentIndex = useEditorUiStore(
97
- (state) => state.currentDocumentIndex,
98
- )
99
- const canvasRef = useRef<HTMLCanvasElement | null>(null)
100
- const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
101
- const drawingRef = useRef(false)
102
- const lastPointRef = useRef<DocumentPointer | null>(null)
103
- const boundsRef = useRef<Bounds | null>(null)
104
  const inpaintQueueRef = useRef<Promise<void>>(Promise.resolve())
105
- const isRepairMode = mode === 'repairBrush'
106
  const isEraseMode = mode === 'eraser'
107
- const isActive = enabled && (isRepairMode || isEraseMode)
108
-
109
- // Reset drawing state when interaction is disabled so stale strokes don't carry over.
110
- useEffect(() => {
111
- if (enabled) return
112
- drawingRef.current = false
113
- lastPointRef.current = null
114
- boundsRef.current = null
115
- inpaintQueueRef.current = Promise.resolve()
116
- }, [enabled, mode])
117
-
118
- useEffect(() => {
119
- const canvas = canvasRef.current
120
- if (!canvas) return
121
- const ctx = canvas.getContext('2d')
122
- ctxRef.current = ctx
123
 
124
- if (!currentDocument) {
125
- canvas.width = 0
126
- canvas.height = 0
127
- ctx?.clearRect(0, 0, canvas.width, canvas.height)
128
- return
129
- }
130
-
131
- const needsResize =
132
- canvas.width !== currentDocument.width ||
133
- canvas.height !== currentDocument.height
134
-
135
- if (needsResize) {
136
- canvas.width = currentDocument.width
137
- canvas.height = currentDocument.height
138
- ctx?.clearRect(0, 0, canvas.width, canvas.height)
139
- ctx?.save()
140
- ctx && (ctx.fillStyle = '#000')
141
- ctx?.fillRect(0, 0, canvas.width, canvas.height)
142
- ctx?.restore()
143
- }
144
 
145
- let cancelled = false
146
- if (currentDocument.segment) {
147
- void (async () => {
148
- try {
149
- const bitmap = await convertToImageBitmap(currentDocument.segment!)
150
- if (cancelled) {
 
 
 
 
 
 
 
 
 
 
 
151
  bitmap.close()
152
- return
 
153
  }
154
- // Redraw atomically once the new bitmap is ready so the previous
155
- // mask stays visible until swap, avoiding a flicker.
156
- ctx?.save()
157
- ctx?.clearRect(0, 0, canvas.width, canvas.height)
158
- ctx?.drawImage(
159
- bitmap,
160
- 0,
161
- 0,
162
- currentDocument.width,
163
- currentDocument.height,
164
- )
165
- ctx?.restore()
166
- bitmap.close()
167
- } catch (error) {
168
- console.error(error)
169
- }
170
- })()
171
- }
172
-
173
- return () => {
174
- cancelled = true
175
- drawingRef.current = false
176
- lastPointRef.current = null
177
- boundsRef.current = null
178
- inpaintQueueRef.current = Promise.resolve()
179
- }
180
- }, [
181
- currentDocument?.id,
182
- currentDocument?.width,
183
- currentDocument?.height,
184
- currentDocument?.segment,
185
- ])
186
-
187
- const drawStroke = (from: DocumentPointer, to: DocumentPointer) => {
188
- const ctx = ctxRef.current
189
- if (!ctx) return
190
- ctx.save()
191
- ctx.lineCap = 'round'
192
- ctx.lineJoin = 'round'
193
- ctx.lineWidth = brushSize
194
- ctx.strokeStyle = isEraseMode ? '#000000' : '#ffffff'
195
- ctx.fillStyle = ctx.strokeStyle
196
- ctx.globalCompositeOperation = 'source-over'
197
-
198
- ctx.beginPath()
199
- ctx.moveTo(from.x, from.y)
200
- ctx.lineTo(to.x, to.y)
201
- ctx.stroke()
202
- ctx.restore()
203
- }
204
-
205
- const exportMaskBytes = async (): Promise<Uint8Array | null> => {
206
- const canvas = canvasRef.current
207
- if (!canvas) return null
208
- const blob = await new Promise<Blob | null>((resolve) => {
209
- canvas.toBlob((result) => resolve(result), 'image/png')
210
- })
211
- if (!blob) return null
212
- return blobToUint8Array(blob)
213
- }
214
-
215
- const exportMaskPatch = async (
216
- region: InpaintRegion,
217
- ): Promise<Uint8Array | null> => {
218
- const canvas = canvasRef.current
219
- if (!canvas || region.width <= 0 || region.height <= 0) return null
220
-
221
- const tempCanvas = document.createElement('canvas')
222
- tempCanvas.width = region.width
223
- tempCanvas.height = region.height
224
- const tempCtx = tempCanvas.getContext('2d')
225
- if (!tempCtx) return null
226
-
227
- tempCtx.drawImage(
228
- canvas,
229
- region.x,
230
- region.y,
231
- region.width,
232
- region.height,
233
- 0,
234
- 0,
235
- region.width,
236
- region.height,
237
- )
238
-
239
- const blob = await new Promise<Blob | null>((resolve) => {
240
- tempCanvas.toBlob((result) => resolve(result), 'image/png')
241
- })
242
- if (!blob) return null
243
- return blobToUint8Array(blob)
244
- }
245
-
246
- const queueInpaint = (task: () => Promise<void>) => {
247
- inpaintQueueRef.current = inpaintQueueRef.current.catch(() => {}).then(task)
248
- }
249
-
250
- const finalizeStroke = () => {
251
- if (!isActive) return
252
- const strokeBounds = boundsRef.current
253
- if (!currentDocument || !strokeBounds) return
254
- const patchRegion = boundsToRegion(strokeBounds, currentDocument)
255
- const region = withMargin(strokeBounds, brushSize, currentDocument)
256
- boundsRef.current = null
257
- drawingRef.current = false
258
- lastPointRef.current = null
259
-
260
- void (async () => {
261
- const [maskBytes, patchBytes] = await Promise.all([
262
- exportMaskBytes(),
263
- exportMaskPatch(patchRegion),
264
- ])
265
- if (!maskBytes) return
266
  try {
267
- await updateMask(maskBytes, {
268
- patchRegion: patchBytes ? patchRegion : undefined,
269
- patch: patchBytes ?? undefined,
270
  })
271
- } catch (error) {
272
- console.error(error)
273
- }
274
- queueInpaint(async () => {
275
- try {
276
- await inpaintPartial(region, {
277
- index: currentDocumentIndex,
278
- })
279
- } catch (error) {
280
- console.error(error)
281
- }
282
- })
283
- })()
284
- }
285
-
286
- const bind = useDrag(
287
- ({ first, last, event, active }) => {
288
- if (!isActive || !currentDocument) return
289
- const sourceEvent = event as MouseEvent
290
- const point = pointerToDocument(sourceEvent)
291
- if (!point) {
292
- if ((last || !active) && drawingRef.current) {
293
- finalizeStroke()
294
- }
295
- return
296
  }
297
- const clamped = clampToDocument(point, currentDocument)
298
-
299
- if (first) {
300
- drawingRef.current = true
301
- lastPointRef.current = clamped
302
- boundsRef.current = {
303
- minX: clamped.x - brushSize / 2,
304
- minY: clamped.y - brushSize / 2,
305
- maxX: clamped.x + brushSize / 2,
306
- maxY: clamped.y + brushSize / 2,
307
- }
308
- drawStroke(clamped, clamped)
309
- return
 
 
 
 
 
 
310
  }
311
-
312
- if (!drawingRef.current) return
313
- const lastPoint = lastPointRef.current ?? clamped
314
- drawStroke(lastPoint, clamped)
315
- lastPointRef.current = clamped
316
- boundsRef.current = boundsRef.current
317
- ? expandBounds(boundsRef.current, clamped, brushSize / 2)
318
- : {
319
- minX: clamped.x - brushSize / 2,
320
- minY: clamped.y - brushSize / 2,
321
- maxX: clamped.x + brushSize / 2,
322
- maxY: clamped.y + brushSize / 2,
323
  }
324
-
325
- if (last || !active) {
326
- finalizeStroke()
327
- }
328
- },
329
- {
330
- pointer: { buttons: 1, touch: true },
331
- preventDefault: true,
332
- filterTaps: true,
333
- eventOptions: { passive: false },
334
  },
335
- )
336
 
337
- return {
338
- canvasRef,
339
- visible: showMask,
340
- bind,
341
- }
342
  }
 
1
  'use client'
2
 
3
+ import { useCallback, useRef } from 'react'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
 
 
 
6
  import {
7
+ updateMask as updateMaskApi,
8
+ inpaintRegion as inpaintRegionApi,
9
+ } from '@/lib/api/regions/regions'
10
+ import {
11
+ getGetDocumentQueryKey,
12
+ getListDocumentsQueryKey,
13
+ } from '@/lib/api/documents/documents'
14
+ import { convertToImageBitmap } from '@/lib/util'
15
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
16
+ import type { ToolMode } from '@/types'
17
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
18
+ import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
19
+ import { useCanvasDrawing } from '@/hooks/useCanvasDrawing'
20
 
21
  type MaskDrawingOptions = {
22
  mode: ToolMode
23
+ currentDocument: MappedDocument | null
24
  pointerToDocument: PointerToDocumentFn
25
  showMask: boolean
26
  enabled: boolean
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  export function useMaskDrawing({
30
  mode,
31
  currentDocument,
 
33
  showMask,
34
  enabled,
35
  }: MaskDrawingOptions) {
36
+ const queryClient = useQueryClient()
 
 
 
 
 
 
 
 
 
 
 
37
  const inpaintQueueRef = useRef<Promise<void>>(Promise.resolve())
 
38
  const isEraseMode = mode === 'eraser'
39
+ const isActive = enabled && (mode === 'repairBrush' || isEraseMode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ const invalidateDocument = useCallback(
42
+ async (documentId: string) => {
43
+ await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) })
44
+ await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() })
45
+ },
46
+ [queryClient],
47
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ const { canvasRef, bind } = useCanvasDrawing(currentDocument, pointerToDocument, {
50
+ getColor: () => (isEraseMode ? '#000000' : '#ffffff'),
51
+ blendMode: 'source-over',
52
+ getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
53
+ enabled: isActive,
54
+ onCanvasInit: (ctx, doc) => {
55
+ // Fill black then draw existing segment mask on top
56
+ ctx.fillStyle = '#000'
57
+ ctx.fillRect(0, 0, doc.width, doc.height)
58
+ if (doc.segment) {
59
+ void (async () => {
60
+ try {
61
+ const bitmap = await convertToImageBitmap(doc.segment!)
62
+ ctx.save()
63
+ ctx.clearRect(0, 0, doc.width, doc.height)
64
+ ctx.drawImage(bitmap, 0, 0, doc.width, doc.height)
65
+ ctx.restore()
66
  bitmap.close()
67
+ } catch (e) {
68
+ console.error(e)
69
  }
70
+ })()
71
+ }
72
+ },
73
+ onFinalizeFullCanvas: async (fullPng) => {
74
+ const documentId = useEditorUiStore.getState().currentDocumentId
75
+ if (!documentId) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  try {
77
+ await updateMaskApi(documentId, {
78
+ data: Array.from(fullPng),
 
79
  })
80
+ } catch (e) {
81
+ console.error(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  }
83
+ },
84
+ onFinalize: async (_patch, region) => {
85
+ const documentId = useEditorUiStore.getState().currentDocumentId
86
+ if (!documentId) return
87
+ // Compute the inpaint region with margin
88
+ const brushSize = usePreferencesStore.getState().brushConfig.size
89
+ const width = Math.max(brushSize, region.width)
90
+ const height = Math.max(brushSize, region.height)
91
+ const margin = Math.min(width * 0.2, 32)
92
+ const doc = currentDocument!
93
+ const x0 = Math.max(0, Math.floor(region.x - margin))
94
+ const y0 = Math.max(0, Math.floor(region.y - margin))
95
+ const x1 = Math.min(doc.width, Math.ceil(region.x + region.width + margin))
96
+ const y1 = Math.min(doc.height, Math.ceil(region.y + region.height + margin))
97
+ const inpaintRegion = {
98
+ x: x0,
99
+ y: y0,
100
+ width: Math.max(1, x1 - x0),
101
+ height: Math.max(1, y1 - y0),
102
  }
103
+ inpaintQueueRef.current = inpaintQueueRef.current
104
+ .catch(() => {})
105
+ .then(async () => {
106
+ try {
107
+ await inpaintRegionApi(documentId, { region: inpaintRegion })
108
+ await invalidateDocument(documentId)
109
+ useEditorUiStore.getState().setShowInpaintedImage(true)
110
+ } catch (e) {
111
+ console.error(e)
 
 
 
112
  }
113
+ })
 
 
 
 
 
 
 
 
 
114
  },
115
+ })
116
 
117
+ return { canvasRef, visible: showMask, bind }
 
 
 
 
118
  }
ui/hooks/useRenderBrushDrawing.ts CHANGED
@@ -1,274 +1,56 @@
1
  'use client'
2
 
3
- import { useEffect, useRef, type RefObject } from 'react'
4
- import { useDrag } from '@use-gesture/react'
5
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
6
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
7
- import { useMaskMutations } from '@/lib/query/mutations'
8
- import { Document, InpaintRegion, ToolMode } from '@/types'
9
- import { blobToUint8Array } from '@/lib/util'
10
  import {
11
- PointerToDocumentFn,
12
- type DocumentPointer,
13
- } from '@/hooks/usePointerToDocument'
 
 
 
 
 
14
 
15
  type RenderBrushOptions = {
16
  mode: ToolMode
17
- currentDocument: Document | null
18
  pointerToDocument: PointerToDocumentFn
19
  enabled: boolean
20
  action: 'paint' | 'erase'
21
  targetCanvasRef?: RefObject<HTMLCanvasElement | null>
22
  }
23
 
24
- type Bounds = {
25
- minX: number
26
- minY: number
27
- maxX: number
28
- maxY: number
29
- }
30
-
31
- const clampToDocument = (
32
- point: DocumentPointer,
33
- doc?: Document,
34
- ): DocumentPointer => {
35
- if (!doc) return point
36
- return {
37
- x: Math.max(0, Math.min(doc.width, point.x)),
38
- y: Math.max(0, Math.min(doc.height, point.y)),
39
- }
40
- }
41
-
42
- const expandBounds = (bounds: Bounds, point: DocumentPointer, radius: number) =>
43
- ({
44
- minX: Math.min(bounds.minX, point.x - radius),
45
- minY: Math.min(bounds.minY, point.y - radius),
46
- maxX: Math.max(bounds.maxX, point.x + radius),
47
- maxY: Math.max(bounds.maxY, point.y + radius),
48
- }) satisfies Bounds
49
-
50
- const boundsToRegion = (bounds: Bounds, doc: Document): InpaintRegion => {
51
- const x0 = Math.max(0, Math.floor(bounds.minX))
52
- const y0 = Math.max(0, Math.floor(bounds.minY))
53
- const x1 = Math.min(doc.width, Math.ceil(bounds.maxX))
54
- const y1 = Math.min(doc.height, Math.ceil(bounds.maxY))
55
-
56
- return {
57
- x: x0,
58
- y: y0,
59
- width: Math.max(1, x1 - x0),
60
- height: Math.max(1, y1 - y0),
61
- }
62
- }
63
-
64
  export function useRenderBrushDrawing({
65
- mode,
66
  currentDocument,
67
  pointerToDocument,
68
  enabled,
69
  action,
70
  targetCanvasRef,
71
  }: RenderBrushOptions) {
72
- const {
73
- brushConfig: { size: brushSize, color: brushColor },
74
- } = usePreferencesStore()
75
- const { paintRendered } = useMaskMutations()
76
- const currentDocumentIndex = useEditorUiStore(
77
- (state) => state.currentDocumentIndex,
78
- )
79
- const canvasRef = useRef<HTMLCanvasElement | null>(null)
80
- const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
81
- const drawingRef = useRef(false)
82
- const lastPointRef = useRef<DocumentPointer | null>(null)
83
- const boundsRef = useRef<Bounds | null>(null)
84
-
85
- useEffect(() => {
86
- const canvas = canvasRef.current
87
- if (!canvas) return
88
- const ctx = canvas.getContext('2d')
89
- ctxRef.current = ctx
90
-
91
- if (!currentDocument || !enabled) {
92
- canvas.width = 0
93
- canvas.height = 0
94
- ctx?.clearRect(0, 0, canvas.width, canvas.height)
95
- return () => {
96
- drawingRef.current = false
97
- lastPointRef.current = null
98
- boundsRef.current = null
99
- }
100
- }
101
-
102
- const needsResize =
103
- canvas.width !== currentDocument.width ||
104
- canvas.height !== currentDocument.height
105
-
106
- if (needsResize) {
107
- canvas.width = currentDocument.width
108
- canvas.height = currentDocument.height
109
- }
110
- ctx?.clearRect(0, 0, canvas.width, canvas.height)
111
-
112
- return () => {
113
- drawingRef.current = false
114
- lastPointRef.current = null
115
- boundsRef.current = null
116
- }
117
- }, [
118
- currentDocument?.id,
119
- currentDocument?.width,
120
- currentDocument?.height,
121
- mode,
122
  enabled,
123
- ])
124
-
125
- const drawStroke = (from: DocumentPointer, to: DocumentPointer) => {
126
- if (!enabled) return
127
- const isErasing = action === 'erase'
128
- const stroke = (ctx: CanvasRenderingContext2D) => {
129
- ctx.save()
130
- ctx.lineCap = 'round'
131
- ctx.lineJoin = 'round'
132
- ctx.lineWidth = brushSize
133
- ctx.strokeStyle = isErasing ? '#000000' : brushColor
134
- ctx.fillStyle = ctx.strokeStyle
135
- ctx.globalCompositeOperation = isErasing
136
- ? 'destination-out'
137
- : 'source-over'
138
- ctx.beginPath()
139
- ctx.moveTo(from.x, from.y)
140
- ctx.lineTo(to.x, to.y)
141
- ctx.stroke()
142
- ctx.restore()
143
- }
144
-
145
- const ctx = ctxRef.current
146
- if (ctx) stroke(ctx)
147
-
148
- const targetCanvas = targetCanvasRef?.current
149
- const targetCtx = targetCanvas?.getContext('2d')
150
- if (targetCtx) stroke(targetCtx)
151
- }
152
-
153
- const exportPatch = async (
154
- region: InpaintRegion,
155
- ): Promise<Uint8Array | null> => {
156
- const canvas = targetCanvasRef?.current ?? canvasRef.current
157
- if (!canvas || region.width <= 0 || region.height <= 0) return null
158
-
159
- const tempCanvas = document.createElement('canvas')
160
- tempCanvas.width = region.width
161
- tempCanvas.height = region.height
162
- const tempCtx = tempCanvas.getContext('2d')
163
- if (!tempCtx) return null
164
-
165
- tempCtx.drawImage(
166
- canvas,
167
- region.x,
168
- region.y,
169
- region.width,
170
- region.height,
171
- 0,
172
- 0,
173
- region.width,
174
- region.height,
175
- )
176
-
177
- const blob = await new Promise<Blob | null>((resolve) => {
178
- tempCanvas.toBlob((result) => resolve(result), 'image/png')
179
- })
180
- if (!blob) return null
181
- return blobToUint8Array(blob)
182
- }
183
-
184
- const finalizeStroke = () => {
185
- if (!enabled) return
186
- const strokeBounds = boundsRef.current
187
- if (!currentDocument || !strokeBounds) return
188
- const patchRegion = boundsToRegion(strokeBounds, currentDocument)
189
- boundsRef.current = null
190
- drawingRef.current = false
191
- lastPointRef.current = null
192
-
193
- void (async () => {
194
- const patchBytes = await exportPatch(patchRegion)
195
- if (!patchBytes) {
196
- const canvas = canvasRef.current
197
- const ctx = ctxRef.current
198
- if (canvas && ctx) {
199
- ctx.clearRect(0, 0, canvas.width, canvas.height)
200
- }
201
- return
202
- }
203
- try {
204
- await paintRendered(patchBytes, patchRegion, {
205
- index: currentDocumentIndex,
206
- })
207
- } catch (error) {
208
- console.error(error)
209
- }
210
- const canvas = canvasRef.current
211
- const ctx = ctxRef.current
212
- if (canvas && ctx) {
213
- ctx.clearRect(0, 0, canvas.width, canvas.height)
214
- }
215
- })()
216
- }
217
-
218
- const bind = useDrag(
219
- ({ first, last, event, active }) => {
220
- if (!enabled || !currentDocument) return
221
- const sourceEvent = event as MouseEvent
222
- const point = pointerToDocument(sourceEvent)
223
- if (!point) {
224
- if ((last || !active) && drawingRef.current) {
225
- finalizeStroke()
226
- }
227
- return
228
- }
229
- const clamped = clampToDocument(point, currentDocument)
230
-
231
- if (first) {
232
- drawingRef.current = true
233
- lastPointRef.current = clamped
234
- boundsRef.current = {
235
- minX: clamped.x - brushSize / 2,
236
- minY: clamped.y - brushSize / 2,
237
- maxX: clamped.x + brushSize / 2,
238
- maxY: clamped.y + brushSize / 2,
239
- }
240
- drawStroke(clamped, clamped)
241
- return
242
- }
243
-
244
- if (!drawingRef.current) return
245
- const lastPoint = lastPointRef.current ?? clamped
246
- drawStroke(lastPoint, clamped)
247
- lastPointRef.current = clamped
248
- boundsRef.current = boundsRef.current
249
- ? expandBounds(boundsRef.current, clamped, brushSize / 2)
250
- : {
251
- minX: clamped.x - brushSize / 2,
252
- minY: clamped.y - brushSize / 2,
253
- maxX: clamped.x + brushSize / 2,
254
- maxY: clamped.y + brushSize / 2,
255
- }
256
-
257
- if (last || !active) {
258
- finalizeStroke()
259
- }
260
- },
261
- {
262
- pointer: { buttons: 1, touch: true },
263
- preventDefault: true,
264
- filterTaps: true,
265
- eventOptions: { passive: false },
266
  },
267
- )
268
-
269
- return {
270
- canvasRef,
271
- visible: enabled,
272
- bind,
273
- }
274
  }
 
1
  'use client'
2
 
3
+ import type { RefObject } from 'react'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
6
+ import { updateBrushLayer } from '@/lib/api/regions/regions'
 
 
 
7
  import {
8
+ getGetDocumentQueryKey,
9
+ getListDocumentsQueryKey,
10
+ } from '@/lib/api/documents/documents'
11
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
12
+ import type { ToolMode } from '@/types'
13
+ import type { MappedDocument } from '@/hooks/useTextBlocks'
14
+ import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
15
+ import { useCanvasDrawing } from '@/hooks/useCanvasDrawing'
16
 
17
  type RenderBrushOptions = {
18
  mode: ToolMode
19
+ currentDocument: MappedDocument | null
20
  pointerToDocument: PointerToDocumentFn
21
  enabled: boolean
22
  action: 'paint' | 'erase'
23
  targetCanvasRef?: RefObject<HTMLCanvasElement | null>
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export function useRenderBrushDrawing({
 
27
  currentDocument,
28
  pointerToDocument,
29
  enabled,
30
  action,
31
  targetCanvasRef,
32
  }: RenderBrushOptions) {
33
+ const queryClient = useQueryClient()
34
+ const isErasing = action === 'erase'
35
+
36
+ return useCanvasDrawing(currentDocument, pointerToDocument, {
37
+ getColor: () =>
38
+ isErasing ? '#000000' : usePreferencesStore.getState().brushConfig.color,
39
+ blendMode: isErasing ? 'destination-out' : 'source-over',
40
+ getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  enabled,
42
+ targetCanvasRef,
43
+ clearAfterStroke: true,
44
+ onFinalize: async (patch, region) => {
45
+ const documentId = useEditorUiStore.getState().currentDocumentId
46
+ if (!documentId) return
47
+ await updateBrushLayer(documentId, {
48
+ data: Array.from(patch),
49
+ region,
50
+ })
51
+ await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) })
52
+ await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() })
53
+ useEditorUiStore.getState().setShowBrushLayer(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  },
55
+ })
 
 
 
 
 
 
56
  }
ui/hooks/useRpcConnection.ts DELETED
@@ -1,12 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useState } from 'react'
4
- import { subscribeRpcConnection } from '@/lib/backend'
5
-
6
- export const useRpcConnection = () => {
7
- const [connected, setConnected] = useState(false)
8
-
9
- useEffect(() => subscribeRpcConnection(setConnected), [])
10
-
11
- return connected
12
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/hooks/useTextBlocks.ts CHANGED
@@ -1,11 +1,21 @@
1
  'use client'
2
 
3
- import { useEffect, useRef } from 'react'
4
- import { useCurrentDocumentState } from '@/lib/query/hooks'
5
- import { useTextBlockMutations } from '@/lib/query/mutations'
6
- import { createTempTextBlockId } from '@/lib/api'
 
 
 
 
 
7
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
 
8
  import { TextBlock } from '@/types'
 
 
 
 
9
 
10
  const TEXT_BLOCK_RENDER_DEBOUNCE_MS = 250
11
 
@@ -25,9 +35,86 @@ const hasGeometryChange = (updates: Partial<TextBlock>) =>
25
  Object.prototype.hasOwnProperty.call(updates, 'width') ||
26
  Object.prototype.hasOwnProperty.call(updates, 'height')
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  export function useTextBlocks() {
29
- const { currentDocument: document, currentDocumentIndex } =
30
- useCurrentDocumentState()
 
31
  const textBlocks = document?.textBlocks ?? []
32
  const selectedBlockIndex = useEditorUiStore(
33
  (state) => state.selectedBlockIndex,
@@ -35,11 +122,43 @@ export function useTextBlocks() {
35
  const setSelectedBlockIndex = useEditorUiStore(
36
  (state) => state.setSelectedBlockIndex,
37
  )
38
- const { updateTextBlocks, renderTextBlock } = useTextBlockMutations()
39
  const renderTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
40
  new Map(),
41
  )
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  useEffect(() => {
44
  const timers = renderTimersRef.current
45
  return () => {
@@ -59,7 +178,7 @@ export function useTextBlocks() {
59
  clearScheduledRender(index)
60
  const timer = setTimeout(() => {
61
  renderTimersRef.current.delete(index)
62
- void renderTextBlock(undefined, currentDocumentIndex, index)
63
  }, TEXT_BLOCK_RENDER_DEBOUNCE_MS)
64
  renderTimersRef.current.set(index, timer)
65
  }
@@ -77,12 +196,10 @@ export function useTextBlocks() {
77
  ui.setShowTextBlocksOverlay(true)
78
  }
79
 
80
- const doc = document
81
-
82
  if (shouldRenderSprite(updates)) {
83
  if (shouldRenderSpriteImmediately(updates)) {
84
  clearScheduledRender(index)
85
- void renderTextBlock(undefined, currentDocumentIndex, index)
86
  } else {
87
  scheduleRender(index)
88
  }
@@ -123,5 +240,6 @@ export function useTextBlocks() {
123
  replaceBlock,
124
  appendBlock,
125
  removeBlock,
 
126
  }
127
  }
 
1
  'use client'
2
 
3
+ import { useCallback, useEffect, useRef } from 'react'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
+ import {
6
+ useGetDocument,
7
+ getGetDocumentQueryKey,
8
+ getListDocumentsQueryKey,
9
+ } from '@/lib/api/documents/documents'
10
+ import { putTextBlocks } from '@/lib/api/text-blocks/text-blocks'
11
+ import { renderDocument } from '@/lib/api/processing/processing'
12
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
13
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
14
  import { TextBlock } from '@/types'
15
+ import type { DocumentDetail, TextBlockInput } from '@/lib/api/schemas'
16
+
17
+ const createTempTextBlockId = () =>
18
+ `temp:${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`
19
 
20
  const TEXT_BLOCK_RENDER_DEBOUNCE_MS = 250
21
 
 
35
  Object.prototype.hasOwnProperty.call(updates, 'width') ||
36
  Object.prototype.hasOwnProperty.call(updates, 'height')
37
 
38
+ const toUint8Array = (data: number[] | null | undefined): Uint8Array | undefined =>
39
+ data ? new Uint8Array(data) : undefined
40
+
41
+ const mapTextBlock = (
42
+ block: DocumentDetail['textBlocks'][number],
43
+ ): TextBlock => ({
44
+ id: block.id,
45
+ x: block.x,
46
+ y: block.y,
47
+ width: block.width,
48
+ height: block.height,
49
+ confidence: block.confidence,
50
+ linePolygons: block.linePolygons as TextBlock['linePolygons'],
51
+ sourceDirection: block.sourceDirection ?? undefined,
52
+ renderedDirection: block.renderedDirection ?? undefined,
53
+ sourceLanguage: block.sourceLanguage ?? undefined,
54
+ rotationDeg: block.rotationDeg ?? undefined,
55
+ detectedFontSizePx: block.detectedFontSizePx ?? undefined,
56
+ detector: block.detector ?? undefined,
57
+ text: block.text ?? undefined,
58
+ translation: block.translation ?? undefined,
59
+ style: block.style as TextBlock['style'],
60
+ fontPrediction: block.fontPrediction as TextBlock['fontPrediction'],
61
+ rendered: undefined,
62
+ })
63
+
64
+ export type MappedDocument = {
65
+ id: string
66
+ path: string
67
+ name: string
68
+ width: number
69
+ height: number
70
+ revision: number
71
+ textBlocks: TextBlock[]
72
+ image: Uint8Array
73
+ segment?: Uint8Array
74
+ inpainted?: Uint8Array
75
+ brushLayer?: Uint8Array
76
+ rendered?: Uint8Array
77
+ }
78
+
79
+ const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({
80
+ id: detail.id,
81
+ path: detail.path,
82
+ name: detail.name,
83
+ width: detail.width,
84
+ height: detail.height,
85
+ revision: detail.revision,
86
+ textBlocks: detail.textBlocks.map(mapTextBlock),
87
+ image: new Uint8Array(detail.image),
88
+ segment: toUint8Array(detail.segment),
89
+ inpainted: toUint8Array(detail.inpainted),
90
+ brushLayer: toUint8Array(detail.brushLayer),
91
+ rendered: toUint8Array(detail.rendered),
92
+ })
93
+
94
+ const toTextBlockInput = (block: TextBlock): TextBlockInput => ({
95
+ id: block.id ?? null,
96
+ x: block.x,
97
+ y: block.y,
98
+ width: block.width,
99
+ height: block.height,
100
+ text: block.text ?? null,
101
+ translation: block.translation ?? null,
102
+ style: (block.style as any) ?? null,
103
+ })
104
+
105
+ export function useCurrentDocument(): MappedDocument | null {
106
+ const documentId = useEditorUiStore((s) => s.currentDocumentId)
107
+ const { data: detail } = useGetDocument(documentId ?? '', {
108
+ query: { enabled: !!documentId, structuralSharing: false },
109
+ })
110
+ if (!detail) return null
111
+ return mapDocumentDetail(detail)
112
+ }
113
+
114
  export function useTextBlocks() {
115
+ const queryClient = useQueryClient()
116
+ const document = useCurrentDocument()
117
+ const documentId = useEditorUiStore((s) => s.currentDocumentId)
118
  const textBlocks = document?.textBlocks ?? []
119
  const selectedBlockIndex = useEditorUiStore(
120
  (state) => state.selectedBlockIndex,
 
122
  const setSelectedBlockIndex = useEditorUiStore(
123
  (state) => state.setSelectedBlockIndex,
124
  )
 
125
  const renderTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
126
  new Map(),
127
  )
128
 
129
+ const invalidateDocument = useCallback(
130
+ async (docId: string) => {
131
+ await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(docId) })
132
+ await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() })
133
+ },
134
+ [queryClient],
135
+ )
136
+
137
+ const updateTextBlocks = useCallback(
138
+ async (blocks: TextBlock[]) => {
139
+ const docId = useEditorUiStore.getState().currentDocumentId
140
+ if (!docId) return
141
+ await putTextBlocks(docId, blocks.map(toTextBlockInput))
142
+ await invalidateDocument(docId)
143
+ },
144
+ [invalidateDocument],
145
+ )
146
+
147
+ const renderTextBlock = useCallback(
148
+ async (docId: string, textBlockIndex: number) => {
149
+ if (typeof textBlockIndex !== 'number') return
150
+ const { renderEffect, renderStroke } = useEditorUiStore.getState()
151
+ const { fontFamily } = usePreferencesStore.getState()
152
+ await renderDocument(docId, {
153
+ shaderEffect: renderEffect,
154
+ shaderStroke: renderStroke,
155
+ fontFamily,
156
+ })
157
+ await invalidateDocument(docId)
158
+ },
159
+ [invalidateDocument],
160
+ )
161
+
162
  useEffect(() => {
163
  const timers = renderTimersRef.current
164
  return () => {
 
178
  clearScheduledRender(index)
179
  const timer = setTimeout(() => {
180
  renderTimersRef.current.delete(index)
181
+ void renderTextBlock(documentId!, index)
182
  }, TEXT_BLOCK_RENDER_DEBOUNCE_MS)
183
  renderTimersRef.current.set(index, timer)
184
  }
 
196
  ui.setShowTextBlocksOverlay(true)
197
  }
198
 
 
 
199
  if (shouldRenderSprite(updates)) {
200
  if (shouldRenderSpriteImmediately(updates)) {
201
  clearScheduledRender(index)
202
+ void renderTextBlock(documentId!, index)
203
  } else {
204
  scheduleRender(index)
205
  }
 
240
  replaceBlock,
241
  appendBlock,
242
  removeBlock,
243
+ updateTextBlocks,
244
  }
245
  }
ui/lib/api.ts DELETED
@@ -1,822 +0,0 @@
1
- 'use client'
2
-
3
- import { directoryOpen, fileOpen, fileSave } from 'browser-fs-access'
4
- import {
5
- fetchBinary,
6
- fetchJson,
7
- getActivePipelineJobId,
8
- setActivePipelineJobId,
9
- } from '@/lib/backend'
10
- import { reportRpcError } from '@/lib/errors'
11
- import type {
12
- ApiKeyResponse,
13
- BootstrapConfig,
14
- DocumentDetail,
15
- DocumentSummary,
16
- ExportResult,
17
- FontFaceInfo,
18
- JobState,
19
- LlmModelInfo,
20
- LlmState,
21
- MetaInfo,
22
- TextBlockPatch,
23
- } from '@/lib/protocol'
24
- import {
25
- Document,
26
- InpaintRegion,
27
- RenderEffect,
28
- RenderStroke,
29
- TextBlock,
30
- TextStyle,
31
- } from '@/types'
32
- import { toArrayBuffer } from '@/lib/util'
33
-
34
- const documentDetailCache = new Map<string, DocumentDetail>()
35
- const textBlockIdAliases = new Map<string, string>()
36
-
37
- const isTempTextBlockId = (id?: string) => !!id && id.startsWith('temp:')
38
-
39
- const textBlockAliasKey = (documentId: string, textBlockId: string) =>
40
- `${documentId}:${textBlockId}`
41
-
42
- const resolveTextBlockIdAlias = (documentId: string, textBlockId?: string) => {
43
- if (!textBlockId) return undefined
44
- return (
45
- textBlockIdAliases.get(textBlockAliasKey(documentId, textBlockId)) ??
46
- textBlockId
47
- )
48
- }
49
-
50
- const rememberTextBlockAlias = (
51
- documentId: string,
52
- tempId: string | undefined,
53
- realId: string,
54
- ) => {
55
- if (!tempId || !isTempTextBlockId(tempId)) return
56
- textBlockIdAliases.set(textBlockAliasKey(documentId, tempId), realId)
57
- }
58
-
59
- const sameJson = (left: unknown, right: unknown) =>
60
- JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
61
-
62
- const withRpcError = async <T>(
63
- method: string,
64
- fn: () => Promise<T>,
65
- ): Promise<T> => {
66
- try {
67
- return await fn()
68
- } catch (error) {
69
- reportRpcError(method, error)
70
- throw error
71
- }
72
- }
73
-
74
- const toBinaryArray = (value: Uint8Array) => Array.from(value)
75
-
76
- const getDocuments = async (): Promise<DocumentSummary[]> => {
77
- const documents = await fetchJson<DocumentSummary[]>('/documents')
78
- const prunedIds = new Set(documents.map((document) => document.id))
79
- for (const documentId of documentDetailCache.keys()) {
80
- if (!prunedIds.has(documentId)) {
81
- documentDetailCache.delete(documentId)
82
- }
83
- }
84
- return documents
85
- }
86
-
87
- const getDocumentSummaryAtIndex = async (index: number) => {
88
- const documents = await getDocuments()
89
- const summary = documents[index]
90
- if (!summary) {
91
- throw new Error(`Document not found at index ${index}`)
92
- }
93
- return summary
94
- }
95
-
96
- const getDocumentDetail = async (
97
- documentId: string,
98
- ): Promise<DocumentDetail> => {
99
- const detail = await fetchJson<DocumentDetail>(`/documents/${documentId}`)
100
- documentDetailCache.set(documentId, detail)
101
- return detail
102
- }
103
-
104
- const getCachedOrFetchDocumentDetail = async (documentId: string) =>
105
- documentDetailCache.get(documentId) ?? (await getDocumentDetail(documentId))
106
-
107
- const fetchLayer = async (documentId: string, layer: string) => {
108
- const binary = await fetchBinary(`/documents/${documentId}/layers/${layer}`)
109
- return binary.data
110
- }
111
-
112
- const mapTextStyle = (style?: TextStyle) =>
113
- style
114
- ? {
115
- fontFamilies: style.fontFamilies,
116
- fontSize: style.fontSize,
117
- color: style.color,
118
- effect: style.effect,
119
- stroke: style.stroke,
120
- textAlign: style.textAlign,
121
- }
122
- : undefined
123
-
124
- const mapTextBlock = (
125
- block: DocumentDetail['textBlocks'][number],
126
- ): TextBlock => ({
127
- id: block.id,
128
- x: block.x,
129
- y: block.y,
130
- width: block.width,
131
- height: block.height,
132
- confidence: block.confidence,
133
- linePolygons: block.linePolygons ?? undefined,
134
- sourceDirection: block.sourceDirection ?? undefined,
135
- renderedDirection: block.renderedDirection ?? undefined,
136
- sourceLanguage: block.sourceLanguage ?? undefined,
137
- rotationDeg: block.rotationDeg ?? undefined,
138
- detectedFontSizePx: block.detectedFontSizePx ?? undefined,
139
- detector: block.detector ?? undefined,
140
- text: block.text ?? undefined,
141
- translation: block.translation ?? undefined,
142
- style: block.style ?? undefined,
143
- fontPrediction: block.fontPrediction ?? undefined,
144
- rendered: undefined,
145
- })
146
-
147
- const toDocumentDetailBlock = (
148
- block: TextBlock,
149
- id: string,
150
- ): DocumentDetail['textBlocks'][number] => ({
151
- id,
152
- x: block.x,
153
- y: block.y,
154
- width: block.width,
155
- height: block.height,
156
- confidence: block.confidence,
157
- linePolygons: block.linePolygons ?? null,
158
- sourceDirection: block.sourceDirection ?? null,
159
- renderedDirection: block.renderedDirection ?? null,
160
- sourceLanguage: block.sourceLanguage ?? null,
161
- rotationDeg: block.rotationDeg ?? null,
162
- detectedFontSizePx: block.detectedFontSizePx ?? null,
163
- detector: block.detector ?? null,
164
- text: block.text ?? null,
165
- translation: block.translation ?? null,
166
- style: block.style ?? null,
167
- fontPrediction: block.fontPrediction ?? null,
168
- })
169
-
170
- const buildTextBlockPatch = (
171
- next: TextBlock,
172
- previous: DocumentDetail['textBlocks'][number],
173
- ): TextBlockPatch | null => {
174
- const patch: TextBlockPatch = {
175
- text: undefined,
176
- translation: undefined,
177
- x: undefined,
178
- y: undefined,
179
- width: undefined,
180
- height: undefined,
181
- style: undefined,
182
- }
183
-
184
- if ((next.text ?? null) !== previous.text) {
185
- patch.text = next.text ?? ''
186
- }
187
- if ((next.translation ?? null) !== previous.translation) {
188
- patch.translation = next.translation ?? ''
189
- }
190
- if (next.x !== previous.x) {
191
- patch.x = next.x
192
- }
193
- if (next.y !== previous.y) {
194
- patch.y = next.y
195
- }
196
- if (next.width !== previous.width) {
197
- patch.width = next.width
198
- }
199
- if (next.height !== previous.height) {
200
- patch.height = next.height
201
- }
202
- if (!sameJson(mapTextStyle(next.style), previous.style)) {
203
- patch.style = mapTextStyle(next.style)
204
- }
205
-
206
- return Object.values(patch).some((value) => value !== undefined)
207
- ? patch
208
- : null
209
- }
210
-
211
- const createTextBlockRemotely = async (
212
- documentId: string,
213
- block: TextBlock,
214
- ) => {
215
- const created = await fetchJson<DocumentDetail['textBlocks'][number]>(
216
- `/documents/${documentId}/text-blocks`,
217
- {
218
- method: 'POST',
219
- headers: { 'content-type': 'application/json' },
220
- body: JSON.stringify({
221
- x: block.x,
222
- y: block.y,
223
- width: block.width,
224
- height: block.height,
225
- }),
226
- },
227
- )
228
-
229
- const patch = buildTextBlockPatch(block, created)
230
- if (patch) {
231
- const updated = await fetchJson<DocumentDetail['textBlocks'][number]>(
232
- `/documents/${documentId}/text-blocks/${created.id}`,
233
- {
234
- method: 'PATCH',
235
- headers: { 'content-type': 'application/json' },
236
- body: JSON.stringify(patch),
237
- },
238
- )
239
- return updated
240
- }
241
-
242
- return created
243
- }
244
-
245
- const getTextBlockIdByIndex = async (
246
- index: number,
247
- textBlockIndex?: number,
248
- ) => {
249
- if (typeof textBlockIndex !== 'number') return undefined
250
- const summary = await getDocumentSummaryAtIndex(index)
251
- const detail = await getCachedOrFetchDocumentDetail(summary.id)
252
- return detail.textBlocks[textBlockIndex]?.id
253
- }
254
-
255
- export const createTempTextBlockId = () =>
256
- `temp:${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`
257
-
258
- export const api = {
259
- async getBootstrapConfig(): Promise<BootstrapConfig> {
260
- return fetchJson<BootstrapConfig>('/config')
261
- },
262
-
263
- async saveBootstrapConfig(config: BootstrapConfig): Promise<BootstrapConfig> {
264
- return fetchJson<BootstrapConfig>('/config', {
265
- method: 'PUT',
266
- headers: { 'content-type': 'application/json' },
267
- body: JSON.stringify(config),
268
- })
269
- },
270
-
271
- async initializeBootstrap(): Promise<void> {
272
- await fetchJson<void>('/initialize', {
273
- method: 'POST',
274
- })
275
- },
276
-
277
- async appVersion(): Promise<string> {
278
- const meta = await fetchJson<MetaInfo>('/meta')
279
- return meta.version
280
- },
281
-
282
- async deviceInfo(): Promise<{ mlDevice: string }> {
283
- const meta = await fetchJson<MetaInfo>('/meta')
284
- return { mlDevice: meta.mlDevice }
285
- },
286
-
287
- async openExternal(url: string): Promise<void> {
288
- if (typeof window !== 'undefined') {
289
- window.open(url, '_blank', 'noopener,noreferrer')
290
- }
291
- },
292
-
293
- async getDocumentsCount(): Promise<number> {
294
- const documents = await getDocuments()
295
- return documents.length
296
- },
297
-
298
- async getDocument(index: number): Promise<Document> {
299
- return withRpcError('get_document', async () => {
300
- const summary = await getDocumentSummaryAtIndex(index)
301
- const detail = await getDocumentDetail(summary.id)
302
- const [image, segment, inpainted, brushLayer, rendered] =
303
- await Promise.all([
304
- fetchLayer(summary.id, 'original'),
305
- summary.hasSegment
306
- ? fetchLayer(summary.id, 'segment')
307
- : Promise.resolve(undefined),
308
- summary.hasInpainted
309
- ? fetchLayer(summary.id, 'inpainted')
310
- : Promise.resolve(undefined),
311
- summary.hasBrushLayer
312
- ? fetchLayer(summary.id, 'brush')
313
- : Promise.resolve(undefined),
314
- summary.hasRendered
315
- ? fetchLayer(summary.id, 'rendered')
316
- : Promise.resolve(undefined),
317
- ])
318
-
319
- return {
320
- id: detail.id,
321
- path: detail.path,
322
- name: detail.name,
323
- image,
324
- width: detail.width,
325
- height: detail.height,
326
- revision: detail.revision,
327
- textBlocks: detail.textBlocks.map(mapTextBlock),
328
- segment,
329
- inpainted,
330
- brushLayer,
331
- rendered,
332
- }
333
- })
334
- },
335
-
336
- async getThumbnail(index: number): Promise<Blob> {
337
- return withRpcError('get_thumbnail', async () => {
338
- const summary = await getDocumentSummaryAtIndex(index)
339
- const binary = await fetchBinary(`/documents/${summary.id}/thumbnail`)
340
- return new Blob([toArrayBuffer(binary.data)], {
341
- type: binary.contentType,
342
- })
343
- })
344
- },
345
-
346
- async addDocuments(): Promise<number> {
347
- return withRpcError('add_documents', async () => {
348
- const files = await pickDocuments()
349
- if (!files?.length) return 0
350
- const formData = new FormData()
351
- files.forEach((file) => formData.append('files', file, file.name))
352
- const result = await fetchJson<{ totalCount: number }>(
353
- '/documents/import?mode=append',
354
- {
355
- method: 'POST',
356
- body: formData,
357
- },
358
- )
359
- documentDetailCache.clear()
360
- return result.totalCount
361
- })
362
- },
363
-
364
- async openDocuments(): Promise<number> {
365
- return withRpcError('open_documents', async () => {
366
- const files = await pickDocuments()
367
- if (!files?.length) return 0
368
- const formData = new FormData()
369
- files.forEach((file) => formData.append('files', file, file.name))
370
- const result = await fetchJson<{ totalCount: number }>(
371
- '/documents/import?mode=replace',
372
- {
373
- method: 'POST',
374
- body: formData,
375
- },
376
- )
377
- documentDetailCache.clear()
378
- return result.totalCount
379
- })
380
- },
381
-
382
- async openFolder(): Promise<number> {
383
- return withRpcError('open_documents', async () => {
384
- const files = await pickFolder()
385
- if (!files?.length) return 0
386
- const formData = new FormData()
387
- files.forEach((file) => formData.append('files', file, file.name))
388
- const result = await fetchJson<{ totalCount: number }>(
389
- '/documents/import?mode=replace',
390
- {
391
- method: 'POST',
392
- body: formData,
393
- },
394
- )
395
- documentDetailCache.clear()
396
- return result.totalCount
397
- })
398
- },
399
-
400
- async addFolder(): Promise<number> {
401
- return withRpcError('add_documents', async () => {
402
- const files = await pickFolder()
403
- if (!files?.length) return 0
404
- const formData = new FormData()
405
- files.forEach((file) => formData.append('files', file, file.name))
406
- const result = await fetchJson<{ totalCount: number }>(
407
- '/documents/import?mode=append',
408
- {
409
- method: 'POST',
410
- body: formData,
411
- },
412
- )
413
- documentDetailCache.clear()
414
- return result.totalCount
415
- })
416
- },
417
-
418
- async exportDocument(index: number): Promise<void> {
419
- return withRpcError('export_document', async () => {
420
- const summary = await getDocumentSummaryAtIndex(index)
421
- const file = await fetchBinary(
422
- `/documents/${summary.id}/export?layer=rendered`,
423
- )
424
- const blob = new Blob([toArrayBuffer(file.data)], {
425
- type: file.contentType,
426
- })
427
- try {
428
- await fileSave(blob, {
429
- fileName: file.filename ?? `${summary.name}_koharu.webp`,
430
- })
431
- } catch {}
432
- })
433
- },
434
-
435
- async exportPsdDocument(index: number): Promise<void> {
436
- return withRpcError('export_psd_document', async () => {
437
- const summary = await getDocumentSummaryAtIndex(index)
438
- const file = await fetchBinary(`/documents/${summary.id}/export/psd`)
439
- const blob = new Blob([toArrayBuffer(file.data)], {
440
- type: file.contentType,
441
- })
442
- try {
443
- await fileSave(blob, {
444
- fileName: file.filename ?? `${summary.name}_koharu.psd`,
445
- })
446
- } catch {}
447
- })
448
- },
449
-
450
- async exportAllInpainted(): Promise<number> {
451
- return withRpcError('export_all_inpainted', async () => {
452
- const result = await fetchJson<ExportResult>('/exports?layer=inpainted', {
453
- method: 'POST',
454
- })
455
- return result.count
456
- })
457
- },
458
-
459
- async exportAllRendered(): Promise<number> {
460
- return withRpcError('export_all_rendered', async () => {
461
- const result = await fetchJson<ExportResult>('/exports?layer=rendered', {
462
- method: 'POST',
463
- })
464
- return result.count
465
- })
466
- },
467
-
468
- async detect(index: number): Promise<void> {
469
- return withRpcError('detect', async () => {
470
- const summary = await getDocumentSummaryAtIndex(index)
471
- await fetchJson<void>(`/documents/${summary.id}/detect`, {
472
- method: 'POST',
473
- })
474
- documentDetailCache.delete(summary.id)
475
- })
476
- },
477
-
478
- async ocr(index: number): Promise<void> {
479
- return withRpcError('ocr', async () => {
480
- const summary = await getDocumentSummaryAtIndex(index)
481
- await fetchJson<void>(`/documents/${summary.id}/ocr`, { method: 'POST' })
482
- documentDetailCache.delete(summary.id)
483
- })
484
- },
485
-
486
- async inpaint(index: number): Promise<void> {
487
- return withRpcError('inpaint', async () => {
488
- const summary = await getDocumentSummaryAtIndex(index)
489
- await fetchJson<void>(`/documents/${summary.id}/inpaint`, {
490
- method: 'POST',
491
- })
492
- documentDetailCache.delete(summary.id)
493
- })
494
- },
495
-
496
- async updateInpaintMask(
497
- index: number,
498
- mask: Uint8Array,
499
- region?: InpaintRegion,
500
- ): Promise<void> {
501
- return withRpcError('update_inpaint_mask', async () => {
502
- const summary = await getDocumentSummaryAtIndex(index)
503
- await fetchJson<void>(`/documents/${summary.id}/mask-region`, {
504
- method: 'PUT',
505
- headers: { 'content-type': 'application/json' },
506
- body: JSON.stringify({
507
- data: toBinaryArray(mask),
508
- region,
509
- }),
510
- })
511
- })
512
- },
513
-
514
- async updateBrushLayer(
515
- index: number,
516
- patch: Uint8Array,
517
- region: InpaintRegion,
518
- ): Promise<void> {
519
- return withRpcError('update_brush_layer', async () => {
520
- const summary = await getDocumentSummaryAtIndex(index)
521
- await fetchJson<void>(`/documents/${summary.id}/brush-region`, {
522
- method: 'PUT',
523
- headers: { 'content-type': 'application/json' },
524
- body: JSON.stringify({
525
- data: toBinaryArray(patch),
526
- region,
527
- }),
528
- })
529
- })
530
- },
531
-
532
- async inpaintPartial(index: number, region: InpaintRegion): Promise<void> {
533
- return withRpcError('inpaint_partial', async () => {
534
- const summary = await getDocumentSummaryAtIndex(index)
535
- await fetchJson<void>(`/documents/${summary.id}/inpaint-region`, {
536
- method: 'POST',
537
- headers: { 'content-type': 'application/json' },
538
- body: JSON.stringify({ region }),
539
- })
540
- documentDetailCache.delete(summary.id)
541
- })
542
- },
543
-
544
- async render(
545
- index: number,
546
- options?: {
547
- textBlockIndex?: number
548
- shaderEffect?: RenderEffect
549
- shaderStroke?: RenderStroke
550
- fontFamily?: string
551
- },
552
- ): Promise<void> {
553
- return withRpcError('render', async () => {
554
- const summary = await getDocumentSummaryAtIndex(index)
555
- const textBlockId = await getTextBlockIdByIndex(
556
- index,
557
- options?.textBlockIndex,
558
- )
559
- await fetchJson<void>(`/documents/${summary.id}/render`, {
560
- method: 'POST',
561
- headers: { 'content-type': 'application/json' },
562
- body: JSON.stringify({
563
- textBlockId,
564
- shaderEffect: options?.shaderEffect,
565
- shaderStroke: options?.shaderStroke,
566
- fontFamily: options?.fontFamily,
567
- }),
568
- })
569
- documentDetailCache.delete(summary.id)
570
- })
571
- },
572
-
573
- async updateTextBlocks(
574
- index: number,
575
- textBlocks: TextBlock[],
576
- ): Promise<void> {
577
- return withRpcError('update_text_blocks', async () => {
578
- const summary = await getDocumentSummaryAtIndex(index)
579
- const previous = await getCachedOrFetchDocumentDetail(summary.id)
580
- const previousMap = new Map(
581
- previous.textBlocks.map((block) => [block.id, block]),
582
- )
583
-
584
- const normalizedBlocks = textBlocks.map((block) => ({
585
- ...block,
586
- id: resolveTextBlockIdAlias(summary.id, block.id),
587
- }))
588
-
589
- const retainedIds = new Set(
590
- normalizedBlocks
591
- .map((block) => block.id)
592
- .filter((id): id is string => !!id && !isTempTextBlockId(id)),
593
- )
594
-
595
- for (const previousBlock of previous.textBlocks) {
596
- if (!retainedIds.has(previousBlock.id)) {
597
- await fetchJson<void>(
598
- `/documents/${summary.id}/text-blocks/${previousBlock.id}`,
599
- {
600
- method: 'DELETE',
601
- },
602
- )
603
- }
604
- }
605
-
606
- const synchronizedBlocks: DocumentDetail['textBlocks'] = []
607
-
608
- for (const block of normalizedBlocks) {
609
- const existingId =
610
- block.id && !isTempTextBlockId(block.id) ? block.id : undefined
611
-
612
- if (!existingId || !previousMap.has(existingId)) {
613
- const created = await createTextBlockRemotely(summary.id, block)
614
- rememberTextBlockAlias(summary.id, block.id, created.id)
615
- synchronizedBlocks.push(
616
- toDocumentDetailBlock({ ...block, id: created.id }, created.id),
617
- )
618
- continue
619
- }
620
-
621
- const previousBlock = previousMap.get(existingId)!
622
- const patch = buildTextBlockPatch(block, previousBlock)
623
- if (patch) {
624
- await fetchJson<DocumentDetail['textBlocks'][number]>(
625
- `/documents/${summary.id}/text-blocks/${existingId}`,
626
- {
627
- method: 'PATCH',
628
- headers: { 'content-type': 'application/json' },
629
- body: JSON.stringify(patch),
630
- },
631
- )
632
- }
633
-
634
- synchronizedBlocks.push(toDocumentDetailBlock(block, existingId))
635
- }
636
-
637
- documentDetailCache.set(summary.id, {
638
- ...previous,
639
- textBlocks: synchronizedBlocks,
640
- })
641
- })
642
- },
643
-
644
- async listFonts(): Promise<FontFaceInfo[]> {
645
- return fetchJson<FontFaceInfo[]>('/fonts')
646
- },
647
-
648
- async getApiKey(provider: string): Promise<string | null> {
649
- const response = await fetchJson<ApiKeyResponse>(
650
- `/providers/${provider}/api-key`,
651
- )
652
- return response.apiKey ?? null
653
- },
654
-
655
- async setApiKey(provider: string, apiKey: string): Promise<void> {
656
- await fetchJson<void>(`/providers/${provider}/api-key`, {
657
- method: 'PUT',
658
- headers: { 'content-type': 'application/json' },
659
- body: JSON.stringify({ apiKey }),
660
- })
661
- },
662
-
663
- async llmList(
664
- language?: string,
665
- openAiCompatibleBaseUrl?: string,
666
- ): Promise<LlmModelInfo[]> {
667
- const params = new URLSearchParams()
668
- if (language) {
669
- params.set('language', language)
670
- }
671
- if (openAiCompatibleBaseUrl) {
672
- params.set('openaiCompatibleBaseUrl', openAiCompatibleBaseUrl)
673
- }
674
- const queryString = params.toString()
675
- const query = queryString ? `?${queryString}` : ''
676
- return fetchJson<LlmModelInfo[]>(`/llm/models${query}`)
677
- },
678
-
679
- async llmLoad(
680
- id: string,
681
- apiKey?: string,
682
- baseUrl?: string,
683
- temperature?: number | null,
684
- maxTokens?: number | null,
685
- customSystemPrompt?: string,
686
- ): Promise<void> {
687
- await fetchJson<LlmState>('/llm/load', {
688
- method: 'POST',
689
- headers: { 'content-type': 'application/json' },
690
- body: JSON.stringify({
691
- id,
692
- apiKey,
693
- baseUrl,
694
- temperature: temperature ?? undefined,
695
- maxTokens: maxTokens ?? undefined,
696
- customSystemPrompt: customSystemPrompt || undefined,
697
- }),
698
- })
699
- },
700
-
701
- async llmPing(
702
- baseUrl: string,
703
- apiKey?: string,
704
- ): Promise<{
705
- ok: boolean
706
- models: string[]
707
- latencyMs?: number
708
- error?: string
709
- }> {
710
- return fetchJson('/llm/ping', {
711
- method: 'POST',
712
- headers: { 'content-type': 'application/json' },
713
- body: JSON.stringify({ baseUrl, apiKey: apiKey || undefined }),
714
- })
715
- },
716
-
717
- async llmOffload(): Promise<void> {
718
- await fetchJson<LlmState>('/llm/offload', {
719
- method: 'POST',
720
- })
721
- },
722
-
723
- async llmReady(selectedModel?: string): Promise<boolean> {
724
- const state = await fetchJson<LlmState>('/llm/state')
725
- return (
726
- state.status === 'ready' &&
727
- (!selectedModel || !state.modelId || state.modelId === selectedModel)
728
- )
729
- },
730
-
731
- async llmGenerate(
732
- index: number,
733
- textBlockIndex?: number,
734
- language?: string,
735
- ): Promise<void> {
736
- return withRpcError('llm_generate', async () => {
737
- const summary = await getDocumentSummaryAtIndex(index)
738
- const textBlockId = await getTextBlockIdByIndex(index, textBlockIndex)
739
- await fetchJson<void>(`/documents/${summary.id}/translate`, {
740
- method: 'POST',
741
- headers: { 'content-type': 'application/json' },
742
- body: JSON.stringify({
743
- textBlockId,
744
- language,
745
- }),
746
- })
747
- documentDetailCache.delete(summary.id)
748
- })
749
- },
750
-
751
- async process(options: {
752
- index?: number
753
- llmModelId?: string
754
- llmApiKey?: string
755
- llmBaseUrl?: string
756
- llmTemperature?: number | null
757
- llmMaxTokens?: number | null
758
- llmCustomSystemPrompt?: string
759
- language?: string
760
- shaderEffect?: RenderEffect
761
- shaderStroke?: RenderStroke
762
- fontFamily?: string
763
- }): Promise<void> {
764
- return withRpcError('process', async () => {
765
- const documentId =
766
- typeof options.index === 'number'
767
- ? (await getDocumentSummaryAtIndex(options.index)).id
768
- : undefined
769
-
770
- const job = await fetchJson<JobState>('/jobs/pipeline', {
771
- method: 'POST',
772
- headers: { 'content-type': 'application/json' },
773
- body: JSON.stringify({
774
- documentId,
775
- llmModelId: options.llmModelId,
776
- llmApiKey: options.llmApiKey,
777
- llmBaseUrl: options.llmBaseUrl,
778
- llmTemperature: options.llmTemperature ?? undefined,
779
- llmMaxTokens: options.llmMaxTokens ?? undefined,
780
- llmCustomSystemPrompt: options.llmCustomSystemPrompt || undefined,
781
- language: options.language,
782
- shaderEffect: options.shaderEffect,
783
- shaderStroke: options.shaderStroke,
784
- fontFamily: options.fontFamily,
785
- }),
786
- })
787
- setActivePipelineJobId(job.id)
788
- })
789
- },
790
-
791
- async processCancel(): Promise<void> {
792
- const jobId = getActivePipelineJobId()
793
- if (!jobId) return
794
- await fetchJson<void>(`/jobs/${jobId}`, { method: 'DELETE' })
795
- },
796
- }
797
-
798
- const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
799
-
800
- const pickDocuments = async (): Promise<File[] | null> => {
801
- try {
802
- return await fileOpen({
803
- description: 'Documents',
804
- mimeTypes: ['image/*'],
805
- extensions: IMAGE_EXTENSIONS,
806
- multiple: true,
807
- })
808
- } catch {
809
- return null
810
- }
811
- }
812
-
813
- const pickFolder = async (): Promise<File[] | null> => {
814
- try {
815
- const files = await directoryOpen({ recursive: true })
816
- return files.filter((file) =>
817
- IMAGE_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext)),
818
- )
819
- } catch {
820
- return null
821
- }
822
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/lib/api/documents/documents.ts ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation,
8
+ useQuery
9
+ } from '@tanstack/react-query';
10
+ import type {
11
+ DataTag,
12
+ DefinedInitialDataOptions,
13
+ DefinedUseQueryResult,
14
+ MutationFunction,
15
+ QueryClient,
16
+ QueryFunction,
17
+ QueryKey,
18
+ UndefinedInitialDataOptions,
19
+ UseMutationOptions,
20
+ UseMutationResult,
21
+ UseQueryOptions,
22
+ UseQueryResult
23
+ } from '@tanstack/react-query';
24
+
25
+ import type {
26
+ ApiError,
27
+ DocumentDetail,
28
+ DocumentSummary,
29
+ GetDocumentThumbnailParams,
30
+ ImportDocumentsBody,
31
+ ImportDocumentsParams,
32
+ ImportResult
33
+ } from '../schemas';
34
+
35
+ import { fetchApi } from '.././fetch';
36
+
37
+
38
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
39
+
40
+
41
+
42
+ export const getListDocumentsUrl = () => {
43
+
44
+
45
+
46
+
47
+ return `/api/v1/documents`
48
+ }
49
+
50
+ export const listDocuments = async ( options?: RequestInit): Promise<DocumentSummary[]> => {
51
+
52
+ return fetchApi<DocumentSummary[]>(getListDocumentsUrl(),
53
+ {
54
+ ...options,
55
+ method: 'GET'
56
+
57
+
58
+ }
59
+ );}
60
+
61
+
62
+
63
+
64
+
65
+ export const getListDocumentsQueryKey = () => {
66
+ return [
67
+ `/api/v1/documents`
68
+ ] as const;
69
+ }
70
+
71
+
72
+ export const getListDocumentsQueryOptions = <TData = Awaited<ReturnType<typeof listDocuments>>, TError = ApiError>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
73
+ ) => {
74
+
75
+ const {query: queryOptions, request: requestOptions} = options ?? {};
76
+
77
+ const queryKey = queryOptions?.queryKey ?? getListDocumentsQueryKey();
78
+
79
+
80
+
81
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof listDocuments>>> = ({ signal }) => listDocuments({ signal, ...requestOptions });
82
+
83
+
84
+
85
+
86
+
87
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
88
+ }
89
+
90
+ export type ListDocumentsQueryResult = NonNullable<Awaited<ReturnType<typeof listDocuments>>>
91
+ export type ListDocumentsQueryError = ApiError
92
+
93
+
94
+ export function useListDocuments<TData = Awaited<ReturnType<typeof listDocuments>>, TError = ApiError>(
95
+ options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData>> & Pick<
96
+ DefinedInitialDataOptions<
97
+ Awaited<ReturnType<typeof listDocuments>>,
98
+ TError,
99
+ Awaited<ReturnType<typeof listDocuments>>
100
+ > , 'initialData'
101
+ >, request?: SecondParameter<typeof fetchApi>}
102
+ , queryClient?: QueryClient
103
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
104
+ export function useListDocuments<TData = Awaited<ReturnType<typeof listDocuments>>, TError = ApiError>(
105
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData>> & Pick<
106
+ UndefinedInitialDataOptions<
107
+ Awaited<ReturnType<typeof listDocuments>>,
108
+ TError,
109
+ Awaited<ReturnType<typeof listDocuments>>
110
+ > , 'initialData'
111
+ >, request?: SecondParameter<typeof fetchApi>}
112
+ , queryClient?: QueryClient
113
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
114
+ export function useListDocuments<TData = Awaited<ReturnType<typeof listDocuments>>, TError = ApiError>(
115
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
116
+ , queryClient?: QueryClient
117
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
118
+
119
+ export function useListDocuments<TData = Awaited<ReturnType<typeof listDocuments>>, TError = ApiError>(
120
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDocuments>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
121
+ , queryClient?: QueryClient
122
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
123
+
124
+ const queryOptions = getListDocumentsQueryOptions(options)
125
+
126
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
127
+
128
+ return { ...query, queryKey: queryOptions.queryKey };
129
+ }
130
+
131
+
132
+
133
+
134
+ export const getImportDocumentsUrl = (params?: ImportDocumentsParams,) => {
135
+ const normalizedParams = new URLSearchParams();
136
+
137
+ Object.entries(params || {}).forEach(([key, value]) => {
138
+
139
+ if (value !== undefined) {
140
+ normalizedParams.append(key, value === null ? 'null' : value.toString())
141
+ }
142
+ });
143
+
144
+ const stringifiedParams = normalizedParams.toString();
145
+
146
+ return stringifiedParams.length > 0 ? `/api/v1/documents?${stringifiedParams}` : `/api/v1/documents`
147
+ }
148
+
149
+ export const importDocuments = async (importDocumentsBody: ImportDocumentsBody,
150
+ params?: ImportDocumentsParams, options?: RequestInit): Promise<ImportResult> => {
151
+ const formData = new FormData();
152
+ importDocumentsBody.files.forEach(value => formData.append(`files`, value));
153
+
154
+ return fetchApi<ImportResult>(getImportDocumentsUrl(params),
155
+ {
156
+ ...options,
157
+ method: 'POST'
158
+ ,
159
+ body:
160
+ formData,
161
+ }
162
+ );}
163
+
164
+
165
+
166
+
167
+ export const getImportDocumentsMutationOptions = <TError = ApiError,
168
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importDocuments>>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext>, request?: SecondParameter<typeof fetchApi>}
169
+ ): UseMutationOptions<Awaited<ReturnType<typeof importDocuments>>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext> => {
170
+
171
+ const mutationKey = ['importDocuments'];
172
+ const {mutation: mutationOptions, request: requestOptions} = options ?
173
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
174
+ options
175
+ : {...options, mutation: {...options.mutation, mutationKey}}
176
+ : {mutation: { mutationKey, }, request: undefined};
177
+
178
+
179
+
180
+
181
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof importDocuments>>, {data: ImportDocumentsBody;params?: ImportDocumentsParams}> = (props) => {
182
+ const {data,params} = props ?? {};
183
+
184
+ return importDocuments(data,params,requestOptions)
185
+ }
186
+
187
+
188
+
189
+
190
+
191
+
192
+ return { mutationFn, ...mutationOptions }}
193
+
194
+ export type ImportDocumentsMutationResult = NonNullable<Awaited<ReturnType<typeof importDocuments>>>
195
+ export type ImportDocumentsMutationBody = ImportDocumentsBody
196
+ export type ImportDocumentsMutationError = ApiError
197
+
198
+ export const useImportDocuments = <TError = ApiError,
199
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importDocuments>>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext>, request?: SecondParameter<typeof fetchApi>}
200
+ , queryClient?: QueryClient): UseMutationResult<
201
+ Awaited<ReturnType<typeof importDocuments>>,
202
+ TError,
203
+ {data: ImportDocumentsBody;params?: ImportDocumentsParams},
204
+ TContext
205
+ > => {
206
+ return useMutation(getImportDocumentsMutationOptions(options), queryClient);
207
+ }
208
+ export const getGetDocumentUrl = (documentId: string,) => {
209
+
210
+
211
+
212
+
213
+ return `/api/v1/documents/${documentId}`
214
+ }
215
+
216
+ export const getDocument = async (documentId: string, options?: RequestInit): Promise<DocumentDetail> => {
217
+
218
+ return fetchApi<DocumentDetail>(getGetDocumentUrl(documentId),
219
+ {
220
+ ...options,
221
+ method: 'GET'
222
+
223
+
224
+ }
225
+ );}
226
+
227
+
228
+
229
+
230
+
231
+ export const getGetDocumentQueryKey = (documentId: string,) => {
232
+ return [
233
+ `/api/v1/documents/${documentId}`
234
+ ] as const;
235
+ }
236
+
237
+
238
+ export const getGetDocumentQueryOptions = <TData = Awaited<ReturnType<typeof getDocument>>, TError = ApiError>(documentId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
239
+ ) => {
240
+
241
+ const {query: queryOptions, request: requestOptions} = options ?? {};
242
+
243
+ const queryKey = queryOptions?.queryKey ?? getGetDocumentQueryKey(documentId);
244
+
245
+
246
+
247
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocument>>> = ({ signal }) => getDocument(documentId, { signal, ...requestOptions });
248
+
249
+
250
+
251
+
252
+
253
+ return { queryKey, queryFn, enabled: !!(documentId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
254
+ }
255
+
256
+ export type GetDocumentQueryResult = NonNullable<Awaited<ReturnType<typeof getDocument>>>
257
+ export type GetDocumentQueryError = ApiError
258
+
259
+
260
+ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>, TError = ApiError>(
261
+ documentId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData>> & Pick<
262
+ DefinedInitialDataOptions<
263
+ Awaited<ReturnType<typeof getDocument>>,
264
+ TError,
265
+ Awaited<ReturnType<typeof getDocument>>
266
+ > , 'initialData'
267
+ >, request?: SecondParameter<typeof fetchApi>}
268
+ , queryClient?: QueryClient
269
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
270
+ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>, TError = ApiError>(
271
+ documentId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData>> & Pick<
272
+ UndefinedInitialDataOptions<
273
+ Awaited<ReturnType<typeof getDocument>>,
274
+ TError,
275
+ Awaited<ReturnType<typeof getDocument>>
276
+ > , 'initialData'
277
+ >, request?: SecondParameter<typeof fetchApi>}
278
+ , queryClient?: QueryClient
279
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
280
+ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>, TError = ApiError>(
281
+ documentId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
282
+ , queryClient?: QueryClient
283
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
284
+
285
+ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>, TError = ApiError>(
286
+ documentId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
287
+ , queryClient?: QueryClient
288
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
289
+
290
+ const queryOptions = getGetDocumentQueryOptions(documentId,options)
291
+
292
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
293
+
294
+ return { ...query, queryKey: queryOptions.queryKey };
295
+ }
296
+
297
+
298
+
299
+
300
+ export const getGetDocumentThumbnailUrl = (documentId: string,
301
+ params?: GetDocumentThumbnailParams,) => {
302
+ const normalizedParams = new URLSearchParams();
303
+
304
+ Object.entries(params || {}).forEach(([key, value]) => {
305
+
306
+ if (value !== undefined) {
307
+ normalizedParams.append(key, value === null ? 'null' : value.toString())
308
+ }
309
+ });
310
+
311
+ const stringifiedParams = normalizedParams.toString();
312
+
313
+ return stringifiedParams.length > 0 ? `/api/v1/documents/${documentId}/thumbnail?${stringifiedParams}` : `/api/v1/documents/${documentId}/thumbnail`
314
+ }
315
+
316
+ export const getDocumentThumbnail = async (documentId: string,
317
+ params?: GetDocumentThumbnailParams, options?: RequestInit): Promise<Blob> => {
318
+
319
+ return fetchApi<Blob>(getGetDocumentThumbnailUrl(documentId,params),
320
+ {
321
+ ...options,
322
+ method: 'GET'
323
+
324
+
325
+ }
326
+ );}
327
+
328
+
329
+
330
+
331
+
332
+ export const getGetDocumentThumbnailQueryKey = (documentId: string,
333
+ params?: GetDocumentThumbnailParams,) => {
334
+ return [
335
+ `/api/v1/documents/${documentId}/thumbnail`, ...(params ? [params] : [])
336
+ ] as const;
337
+ }
338
+
339
+
340
+ export const getGetDocumentThumbnailQueryOptions = <TData = Awaited<ReturnType<typeof getDocumentThumbnail>>, TError = ApiError>(documentId: string,
341
+ params?: GetDocumentThumbnailParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
342
+ ) => {
343
+
344
+ const {query: queryOptions, request: requestOptions} = options ?? {};
345
+
346
+ const queryKey = queryOptions?.queryKey ?? getGetDocumentThumbnailQueryKey(documentId,params);
347
+
348
+
349
+
350
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocumentThumbnail>>> = ({ signal }) => getDocumentThumbnail(documentId,params, { signal, ...requestOptions });
351
+
352
+
353
+
354
+
355
+
356
+ return { queryKey, queryFn, enabled: !!(documentId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
357
+ }
358
+
359
+ export type GetDocumentThumbnailQueryResult = NonNullable<Awaited<ReturnType<typeof getDocumentThumbnail>>>
360
+ export type GetDocumentThumbnailQueryError = ApiError
361
+
362
+
363
+ export function useGetDocumentThumbnail<TData = Awaited<ReturnType<typeof getDocumentThumbnail>>, TError = ApiError>(
364
+ documentId: string,
365
+ params: undefined | GetDocumentThumbnailParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData>> & Pick<
366
+ DefinedInitialDataOptions<
367
+ Awaited<ReturnType<typeof getDocumentThumbnail>>,
368
+ TError,
369
+ Awaited<ReturnType<typeof getDocumentThumbnail>>
370
+ > , 'initialData'
371
+ >, request?: SecondParameter<typeof fetchApi>}
372
+ , queryClient?: QueryClient
373
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
374
+ export function useGetDocumentThumbnail<TData = Awaited<ReturnType<typeof getDocumentThumbnail>>, TError = ApiError>(
375
+ documentId: string,
376
+ params?: GetDocumentThumbnailParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData>> & Pick<
377
+ UndefinedInitialDataOptions<
378
+ Awaited<ReturnType<typeof getDocumentThumbnail>>,
379
+ TError,
380
+ Awaited<ReturnType<typeof getDocumentThumbnail>>
381
+ > , 'initialData'
382
+ >, request?: SecondParameter<typeof fetchApi>}
383
+ , queryClient?: QueryClient
384
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
385
+ export function useGetDocumentThumbnail<TData = Awaited<ReturnType<typeof getDocumentThumbnail>>, TError = ApiError>(
386
+ documentId: string,
387
+ params?: GetDocumentThumbnailParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
388
+ , queryClient?: QueryClient
389
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
390
+
391
+ export function useGetDocumentThumbnail<TData = Awaited<ReturnType<typeof getDocumentThumbnail>>, TError = ApiError>(
392
+ documentId: string,
393
+ params?: GetDocumentThumbnailParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentThumbnail>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
394
+ , queryClient?: QueryClient
395
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
396
+
397
+ const queryOptions = getGetDocumentThumbnailQueryOptions(documentId,params,options)
398
+
399
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
400
+
401
+ return { ...query, queryKey: queryOptions.queryKey };
402
+ }
403
+
404
+
405
+
406
+
ui/lib/api/downloads/downloads.ts ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useQuery
8
+ } from '@tanstack/react-query';
9
+ import type {
10
+ DataTag,
11
+ DefinedInitialDataOptions,
12
+ DefinedUseQueryResult,
13
+ QueryClient,
14
+ QueryFunction,
15
+ QueryKey,
16
+ UndefinedInitialDataOptions,
17
+ UseQueryOptions,
18
+ UseQueryResult
19
+ } from '@tanstack/react-query';
20
+
21
+ import type {
22
+ DownloadState
23
+ } from '../schemas';
24
+
25
+ import { fetchApi } from '.././fetch';
26
+
27
+
28
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
29
+
30
+
31
+
32
+ export const getListDownloadsUrl = () => {
33
+
34
+
35
+
36
+
37
+ return `/api/v1/downloads`
38
+ }
39
+
40
+ export const listDownloads = async ( options?: RequestInit): Promise<DownloadState[]> => {
41
+
42
+ return fetchApi<DownloadState[]>(getListDownloadsUrl(),
43
+ {
44
+ ...options,
45
+ method: 'GET'
46
+
47
+
48
+ }
49
+ );}
50
+
51
+
52
+
53
+
54
+
55
+ export const getListDownloadsQueryKey = () => {
56
+ return [
57
+ `/api/v1/downloads`
58
+ ] as const;
59
+ }
60
+
61
+
62
+ export const getListDownloadsQueryOptions = <TData = Awaited<ReturnType<typeof listDownloads>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
63
+ ) => {
64
+
65
+ const {query: queryOptions, request: requestOptions} = options ?? {};
66
+
67
+ const queryKey = queryOptions?.queryKey ?? getListDownloadsQueryKey();
68
+
69
+
70
+
71
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof listDownloads>>> = ({ signal }) => listDownloads({ signal, ...requestOptions });
72
+
73
+
74
+
75
+
76
+
77
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
78
+ }
79
+
80
+ export type ListDownloadsQueryResult = NonNullable<Awaited<ReturnType<typeof listDownloads>>>
81
+ export type ListDownloadsQueryError = unknown
82
+
83
+
84
+ export function useListDownloads<TData = Awaited<ReturnType<typeof listDownloads>>, TError = unknown>(
85
+ options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData>> & Pick<
86
+ DefinedInitialDataOptions<
87
+ Awaited<ReturnType<typeof listDownloads>>,
88
+ TError,
89
+ Awaited<ReturnType<typeof listDownloads>>
90
+ > , 'initialData'
91
+ >, request?: SecondParameter<typeof fetchApi>}
92
+ , queryClient?: QueryClient
93
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
94
+ export function useListDownloads<TData = Awaited<ReturnType<typeof listDownloads>>, TError = unknown>(
95
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData>> & Pick<
96
+ UndefinedInitialDataOptions<
97
+ Awaited<ReturnType<typeof listDownloads>>,
98
+ TError,
99
+ Awaited<ReturnType<typeof listDownloads>>
100
+ > , 'initialData'
101
+ >, request?: SecondParameter<typeof fetchApi>}
102
+ , queryClient?: QueryClient
103
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
104
+ export function useListDownloads<TData = Awaited<ReturnType<typeof listDownloads>>, TError = unknown>(
105
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
106
+ , queryClient?: QueryClient
107
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
108
+
109
+ export function useListDownloads<TData = Awaited<ReturnType<typeof listDownloads>>, TError = unknown>(
110
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listDownloads>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
111
+ , queryClient?: QueryClient
112
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
113
+
114
+ const queryOptions = getListDownloadsQueryOptions(options)
115
+
116
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
117
+
118
+ return { ...query, queryKey: queryOptions.queryKey };
119
+ }
120
+
121
+
122
+
123
+
ui/lib/api/exports/exports.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation,
8
+ useQuery
9
+ } from '@tanstack/react-query';
10
+ import type {
11
+ DataTag,
12
+ DefinedInitialDataOptions,
13
+ DefinedUseQueryResult,
14
+ MutationFunction,
15
+ QueryClient,
16
+ QueryFunction,
17
+ QueryKey,
18
+ UndefinedInitialDataOptions,
19
+ UseMutationOptions,
20
+ UseMutationResult,
21
+ UseQueryOptions,
22
+ UseQueryResult
23
+ } from '@tanstack/react-query';
24
+
25
+ import type {
26
+ ApiError,
27
+ ExportBatchRequest,
28
+ ExportDocumentParams,
29
+ ExportResult
30
+ } from '../schemas';
31
+
32
+ import { fetchApi } from '.././fetch';
33
+
34
+
35
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
36
+
37
+
38
+
39
+ export const getExportDocumentUrl = (documentId: string,
40
+ format: string,
41
+ params?: ExportDocumentParams,) => {
42
+ const normalizedParams = new URLSearchParams();
43
+
44
+ Object.entries(params || {}).forEach(([key, value]) => {
45
+
46
+ if (value !== undefined) {
47
+ normalizedParams.append(key, value === null ? 'null' : value.toString())
48
+ }
49
+ });
50
+
51
+ const stringifiedParams = normalizedParams.toString();
52
+
53
+ return stringifiedParams.length > 0 ? `/api/v1/documents/${documentId}/export/${format}?${stringifiedParams}` : `/api/v1/documents/${documentId}/export/${format}`
54
+ }
55
+
56
+ export const exportDocument = async (documentId: string,
57
+ format: string,
58
+ params?: ExportDocumentParams, options?: RequestInit): Promise<Blob> => {
59
+
60
+ return fetchApi<Blob>(getExportDocumentUrl(documentId,format,params),
61
+ {
62
+ ...options,
63
+ method: 'GET'
64
+
65
+
66
+ }
67
+ );}
68
+
69
+
70
+
71
+
72
+
73
+ export const getExportDocumentQueryKey = (documentId: string,
74
+ format: string,
75
+ params?: ExportDocumentParams,) => {
76
+ return [
77
+ `/api/v1/documents/${documentId}/export/${format}`, ...(params ? [params] : [])
78
+ ] as const;
79
+ }
80
+
81
+
82
+ export const getExportDocumentQueryOptions = <TData = Awaited<ReturnType<typeof exportDocument>>, TError = ApiError>(documentId: string,
83
+ format: string,
84
+ params?: ExportDocumentParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
85
+ ) => {
86
+
87
+ const {query: queryOptions, request: requestOptions} = options ?? {};
88
+
89
+ const queryKey = queryOptions?.queryKey ?? getExportDocumentQueryKey(documentId,format,params);
90
+
91
+
92
+
93
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof exportDocument>>> = ({ signal }) => exportDocument(documentId,format,params, { signal, ...requestOptions });
94
+
95
+
96
+
97
+
98
+
99
+ return { queryKey, queryFn, enabled: !!(documentId && format), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
100
+ }
101
+
102
+ export type ExportDocumentQueryResult = NonNullable<Awaited<ReturnType<typeof exportDocument>>>
103
+ export type ExportDocumentQueryError = ApiError
104
+
105
+
106
+ export function useExportDocument<TData = Awaited<ReturnType<typeof exportDocument>>, TError = ApiError>(
107
+ documentId: string,
108
+ format: string,
109
+ params: undefined | ExportDocumentParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData>> & Pick<
110
+ DefinedInitialDataOptions<
111
+ Awaited<ReturnType<typeof exportDocument>>,
112
+ TError,
113
+ Awaited<ReturnType<typeof exportDocument>>
114
+ > , 'initialData'
115
+ >, request?: SecondParameter<typeof fetchApi>}
116
+ , queryClient?: QueryClient
117
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
118
+ export function useExportDocument<TData = Awaited<ReturnType<typeof exportDocument>>, TError = ApiError>(
119
+ documentId: string,
120
+ format: string,
121
+ params?: ExportDocumentParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData>> & Pick<
122
+ UndefinedInitialDataOptions<
123
+ Awaited<ReturnType<typeof exportDocument>>,
124
+ TError,
125
+ Awaited<ReturnType<typeof exportDocument>>
126
+ > , 'initialData'
127
+ >, request?: SecondParameter<typeof fetchApi>}
128
+ , queryClient?: QueryClient
129
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
130
+ export function useExportDocument<TData = Awaited<ReturnType<typeof exportDocument>>, TError = ApiError>(
131
+ documentId: string,
132
+ format: string,
133
+ params?: ExportDocumentParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
134
+ , queryClient?: QueryClient
135
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
136
+
137
+ export function useExportDocument<TData = Awaited<ReturnType<typeof exportDocument>>, TError = ApiError>(
138
+ documentId: string,
139
+ format: string,
140
+ params?: ExportDocumentParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof exportDocument>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
141
+ , queryClient?: QueryClient
142
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
143
+
144
+ const queryOptions = getExportDocumentQueryOptions(documentId,format,params,options)
145
+
146
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
147
+
148
+ return { ...query, queryKey: queryOptions.queryKey };
149
+ }
150
+
151
+
152
+
153
+
154
+ export const getBatchExportUrl = () => {
155
+
156
+
157
+
158
+
159
+ return `/api/v1/exports`
160
+ }
161
+
162
+ export const batchExport = async (exportBatchRequest: ExportBatchRequest, options?: RequestInit): Promise<ExportResult> => {
163
+
164
+ return fetchApi<ExportResult>(getBatchExportUrl(),
165
+ {
166
+ ...options,
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
169
+ body: JSON.stringify(
170
+ exportBatchRequest,)
171
+ }
172
+ );}
173
+
174
+
175
+
176
+
177
+ export const getBatchExportMutationOptions = <TError = ApiError,
178
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof batchExport>>, TError,{data: ExportBatchRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
179
+ ): UseMutationOptions<Awaited<ReturnType<typeof batchExport>>, TError,{data: ExportBatchRequest}, TContext> => {
180
+
181
+ const mutationKey = ['batchExport'];
182
+ const {mutation: mutationOptions, request: requestOptions} = options ?
183
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
184
+ options
185
+ : {...options, mutation: {...options.mutation, mutationKey}}
186
+ : {mutation: { mutationKey, }, request: undefined};
187
+
188
+
189
+
190
+
191
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof batchExport>>, {data: ExportBatchRequest}> = (props) => {
192
+ const {data} = props ?? {};
193
+
194
+ return batchExport(data,requestOptions)
195
+ }
196
+
197
+
198
+
199
+
200
+
201
+
202
+ return { mutationFn, ...mutationOptions }}
203
+
204
+ export type BatchExportMutationResult = NonNullable<Awaited<ReturnType<typeof batchExport>>>
205
+ export type BatchExportMutationBody = ExportBatchRequest
206
+ export type BatchExportMutationError = ApiError
207
+
208
+ export const useBatchExport = <TError = ApiError,
209
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof batchExport>>, TError,{data: ExportBatchRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
210
+ , queryClient?: QueryClient): UseMutationResult<
211
+ Awaited<ReturnType<typeof batchExport>>,
212
+ TError,
213
+ {data: ExportBatchRequest},
214
+ TContext
215
+ > => {
216
+ return useMutation(getBatchExportMutationOptions(options), queryClient);
217
+ }
ui/lib/api/fetch.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const fetchApi = async <T>(url: string, options?: RequestInit): Promise<T> => {
2
+ const res = await fetch(url, options)
3
+ if (!res.ok) {
4
+ throw await res.json().catch(() => ({ status: res.status, message: res.statusText }))
5
+ }
6
+ if ([204, 205, 304].includes(res.status)) {
7
+ return undefined as T
8
+ }
9
+ const contentType = res.headers.get('content-type') ?? ''
10
+ if (!contentType.includes('json')) {
11
+ return (await res.blob()) as T
12
+ }
13
+ return res.json()
14
+ }
ui/lib/api/jobs/jobs.ts ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation,
8
+ useQuery
9
+ } from '@tanstack/react-query';
10
+ import type {
11
+ DataTag,
12
+ DefinedInitialDataOptions,
13
+ DefinedUseQueryResult,
14
+ MutationFunction,
15
+ QueryClient,
16
+ QueryFunction,
17
+ QueryKey,
18
+ UndefinedInitialDataOptions,
19
+ UseMutationOptions,
20
+ UseMutationResult,
21
+ UseQueryOptions,
22
+ UseQueryResult
23
+ } from '@tanstack/react-query';
24
+
25
+ import type {
26
+ ApiError,
27
+ JobState,
28
+ PipelineJobRequest
29
+ } from '../schemas';
30
+
31
+ import { fetchApi } from '.././fetch';
32
+
33
+
34
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
35
+
36
+
37
+
38
+ export const getListJobsUrl = () => {
39
+
40
+
41
+
42
+
43
+ return `/api/v1/jobs`
44
+ }
45
+
46
+ export const listJobs = async ( options?: RequestInit): Promise<JobState[]> => {
47
+
48
+ return fetchApi<JobState[]>(getListJobsUrl(),
49
+ {
50
+ ...options,
51
+ method: 'GET'
52
+
53
+
54
+ }
55
+ );}
56
+
57
+
58
+
59
+
60
+
61
+ export const getListJobsQueryKey = () => {
62
+ return [
63
+ `/api/v1/jobs`
64
+ ] as const;
65
+ }
66
+
67
+
68
+ export const getListJobsQueryOptions = <TData = Awaited<ReturnType<typeof listJobs>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
69
+ ) => {
70
+
71
+ const {query: queryOptions, request: requestOptions} = options ?? {};
72
+
73
+ const queryKey = queryOptions?.queryKey ?? getListJobsQueryKey();
74
+
75
+
76
+
77
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof listJobs>>> = ({ signal }) => listJobs({ signal, ...requestOptions });
78
+
79
+
80
+
81
+
82
+
83
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
84
+ }
85
+
86
+ export type ListJobsQueryResult = NonNullable<Awaited<ReturnType<typeof listJobs>>>
87
+ export type ListJobsQueryError = unknown
88
+
89
+
90
+ export function useListJobs<TData = Awaited<ReturnType<typeof listJobs>>, TError = unknown>(
91
+ options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData>> & Pick<
92
+ DefinedInitialDataOptions<
93
+ Awaited<ReturnType<typeof listJobs>>,
94
+ TError,
95
+ Awaited<ReturnType<typeof listJobs>>
96
+ > , 'initialData'
97
+ >, request?: SecondParameter<typeof fetchApi>}
98
+ , queryClient?: QueryClient
99
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
100
+ export function useListJobs<TData = Awaited<ReturnType<typeof listJobs>>, TError = unknown>(
101
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData>> & Pick<
102
+ UndefinedInitialDataOptions<
103
+ Awaited<ReturnType<typeof listJobs>>,
104
+ TError,
105
+ Awaited<ReturnType<typeof listJobs>>
106
+ > , 'initialData'
107
+ >, request?: SecondParameter<typeof fetchApi>}
108
+ , queryClient?: QueryClient
109
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
110
+ export function useListJobs<TData = Awaited<ReturnType<typeof listJobs>>, TError = unknown>(
111
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
112
+ , queryClient?: QueryClient
113
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
114
+
115
+ export function useListJobs<TData = Awaited<ReturnType<typeof listJobs>>, TError = unknown>(
116
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listJobs>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
117
+ , queryClient?: QueryClient
118
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
119
+
120
+ const queryOptions = getListJobsQueryOptions(options)
121
+
122
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
123
+
124
+ return { ...query, queryKey: queryOptions.queryKey };
125
+ }
126
+
127
+
128
+
129
+
130
+ export const getStartPipelineUrl = () => {
131
+
132
+
133
+
134
+
135
+ return `/api/v1/jobs/pipeline`
136
+ }
137
+
138
+ export const startPipeline = async (pipelineJobRequest: PipelineJobRequest, options?: RequestInit): Promise<JobState> => {
139
+
140
+ return fetchApi<JobState>(getStartPipelineUrl(),
141
+ {
142
+ ...options,
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
145
+ body: JSON.stringify(
146
+ pipelineJobRequest,)
147
+ }
148
+ );}
149
+
150
+
151
+
152
+
153
+ export const getStartPipelineMutationOptions = <TError = ApiError,
154
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startPipeline>>, TError,{data: PipelineJobRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
155
+ ): UseMutationOptions<Awaited<ReturnType<typeof startPipeline>>, TError,{data: PipelineJobRequest}, TContext> => {
156
+
157
+ const mutationKey = ['startPipeline'];
158
+ const {mutation: mutationOptions, request: requestOptions} = options ?
159
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
160
+ options
161
+ : {...options, mutation: {...options.mutation, mutationKey}}
162
+ : {mutation: { mutationKey, }, request: undefined};
163
+
164
+
165
+
166
+
167
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof startPipeline>>, {data: PipelineJobRequest}> = (props) => {
168
+ const {data} = props ?? {};
169
+
170
+ return startPipeline(data,requestOptions)
171
+ }
172
+
173
+
174
+
175
+
176
+
177
+
178
+ return { mutationFn, ...mutationOptions }}
179
+
180
+ export type StartPipelineMutationResult = NonNullable<Awaited<ReturnType<typeof startPipeline>>>
181
+ export type StartPipelineMutationBody = PipelineJobRequest
182
+ export type StartPipelineMutationError = ApiError
183
+
184
+ export const useStartPipeline = <TError = ApiError,
185
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof startPipeline>>, TError,{data: PipelineJobRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
186
+ , queryClient?: QueryClient): UseMutationResult<
187
+ Awaited<ReturnType<typeof startPipeline>>,
188
+ TError,
189
+ {data: PipelineJobRequest},
190
+ TContext
191
+ > => {
192
+ return useMutation(getStartPipelineMutationOptions(options), queryClient);
193
+ }
194
+ export const getGetJobUrl = (jobId: string,) => {
195
+
196
+
197
+
198
+
199
+ return `/api/v1/jobs/${jobId}`
200
+ }
201
+
202
+ export const getJob = async (jobId: string, options?: RequestInit): Promise<JobState> => {
203
+
204
+ return fetchApi<JobState>(getGetJobUrl(jobId),
205
+ {
206
+ ...options,
207
+ method: 'GET'
208
+
209
+
210
+ }
211
+ );}
212
+
213
+
214
+
215
+
216
+
217
+ export const getGetJobQueryKey = (jobId: string,) => {
218
+ return [
219
+ `/api/v1/jobs/${jobId}`
220
+ ] as const;
221
+ }
222
+
223
+
224
+ export const getGetJobQueryOptions = <TData = Awaited<ReturnType<typeof getJob>>, TError = ApiError>(jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
225
+ ) => {
226
+
227
+ const {query: queryOptions, request: requestOptions} = options ?? {};
228
+
229
+ const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(jobId);
230
+
231
+
232
+
233
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getJob>>> = ({ signal }) => getJob(jobId, { signal, ...requestOptions });
234
+
235
+
236
+
237
+
238
+
239
+ return { queryKey, queryFn, enabled: !!(jobId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
240
+ }
241
+
242
+ export type GetJobQueryResult = NonNullable<Awaited<ReturnType<typeof getJob>>>
243
+ export type GetJobQueryError = ApiError
244
+
245
+
246
+ export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = ApiError>(
247
+ jobId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>> & Pick<
248
+ DefinedInitialDataOptions<
249
+ Awaited<ReturnType<typeof getJob>>,
250
+ TError,
251
+ Awaited<ReturnType<typeof getJob>>
252
+ > , 'initialData'
253
+ >, request?: SecondParameter<typeof fetchApi>}
254
+ , queryClient?: QueryClient
255
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
256
+ export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = ApiError>(
257
+ jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>> & Pick<
258
+ UndefinedInitialDataOptions<
259
+ Awaited<ReturnType<typeof getJob>>,
260
+ TError,
261
+ Awaited<ReturnType<typeof getJob>>
262
+ > , 'initialData'
263
+ >, request?: SecondParameter<typeof fetchApi>}
264
+ , queryClient?: QueryClient
265
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
266
+ export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = ApiError>(
267
+ jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
268
+ , queryClient?: QueryClient
269
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
270
+
271
+ export function useGetJob<TData = Awaited<ReturnType<typeof getJob>>, TError = ApiError>(
272
+ jobId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getJob>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
273
+ , queryClient?: QueryClient
274
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
275
+
276
+ const queryOptions = getGetJobQueryOptions(jobId,options)
277
+
278
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
279
+
280
+ return { ...query, queryKey: queryOptions.queryKey };
281
+ }
282
+
283
+
284
+
285
+
286
+ export const getCancelJobUrl = (jobId: string,) => {
287
+
288
+
289
+
290
+
291
+ return `/api/v1/jobs/${jobId}`
292
+ }
293
+
294
+ export const cancelJob = async (jobId: string, options?: RequestInit): Promise<void> => {
295
+
296
+ return fetchApi<void>(getCancelJobUrl(jobId),
297
+ {
298
+ ...options,
299
+ method: 'DELETE'
300
+
301
+
302
+ }
303
+ );}
304
+
305
+
306
+
307
+
308
+ export const getCancelJobMutationOptions = <TError = ApiError,
309
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof cancelJob>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
310
+ ): UseMutationOptions<Awaited<ReturnType<typeof cancelJob>>, TError,{jobId: string}, TContext> => {
311
+
312
+ const mutationKey = ['cancelJob'];
313
+ const {mutation: mutationOptions, request: requestOptions} = options ?
314
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
315
+ options
316
+ : {...options, mutation: {...options.mutation, mutationKey}}
317
+ : {mutation: { mutationKey, }, request: undefined};
318
+
319
+
320
+
321
+
322
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof cancelJob>>, {jobId: string}> = (props) => {
323
+ const {jobId} = props ?? {};
324
+
325
+ return cancelJob(jobId,requestOptions)
326
+ }
327
+
328
+
329
+
330
+
331
+
332
+
333
+ return { mutationFn, ...mutationOptions }}
334
+
335
+ export type CancelJobMutationResult = NonNullable<Awaited<ReturnType<typeof cancelJob>>>
336
+
337
+ export type CancelJobMutationError = ApiError
338
+
339
+ export const useCancelJob = <TError = ApiError,
340
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof cancelJob>>, TError,{jobId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
341
+ , queryClient?: QueryClient): UseMutationResult<
342
+ Awaited<ReturnType<typeof cancelJob>>,
343
+ TError,
344
+ {jobId: string},
345
+ TContext
346
+ > => {
347
+ return useMutation(getCancelJobMutationOptions(options), queryClient);
348
+ }
ui/lib/api/llm/llm.ts ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation,
8
+ useQuery
9
+ } from '@tanstack/react-query';
10
+ import type {
11
+ DataTag,
12
+ DefinedInitialDataOptions,
13
+ DefinedUseQueryResult,
14
+ MutationFunction,
15
+ QueryClient,
16
+ QueryFunction,
17
+ QueryKey,
18
+ UndefinedInitialDataOptions,
19
+ UseMutationOptions,
20
+ UseMutationResult,
21
+ UseQueryOptions,
22
+ UseQueryResult
23
+ } from '@tanstack/react-query';
24
+
25
+ import type {
26
+ ApiError,
27
+ ListLlmModelsParams,
28
+ LlmLoadRequest,
29
+ LlmModelInfo,
30
+ LlmPingRequest,
31
+ LlmPingResponse,
32
+ LlmState
33
+ } from '../schemas';
34
+
35
+ import { fetchApi } from '.././fetch';
36
+
37
+
38
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
39
+
40
+
41
+
42
+ export const getGetLlmUrl = () => {
43
+
44
+
45
+
46
+
47
+ return `/api/v1/llm`
48
+ }
49
+
50
+ export const getLlm = async ( options?: RequestInit): Promise<LlmState> => {
51
+
52
+ return fetchApi<LlmState>(getGetLlmUrl(),
53
+ {
54
+ ...options,
55
+ method: 'GET'
56
+
57
+
58
+ }
59
+ );}
60
+
61
+
62
+
63
+
64
+
65
+ export const getGetLlmQueryKey = () => {
66
+ return [
67
+ `/api/v1/llm`
68
+ ] as const;
69
+ }
70
+
71
+
72
+ export const getGetLlmQueryOptions = <TData = Awaited<ReturnType<typeof getLlm>>, TError = ApiError>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
73
+ ) => {
74
+
75
+ const {query: queryOptions, request: requestOptions} = options ?? {};
76
+
77
+ const queryKey = queryOptions?.queryKey ?? getGetLlmQueryKey();
78
+
79
+
80
+
81
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getLlm>>> = ({ signal }) => getLlm({ signal, ...requestOptions });
82
+
83
+
84
+
85
+
86
+
87
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
88
+ }
89
+
90
+ export type GetLlmQueryResult = NonNullable<Awaited<ReturnType<typeof getLlm>>>
91
+ export type GetLlmQueryError = ApiError
92
+
93
+
94
+ export function useGetLlm<TData = Awaited<ReturnType<typeof getLlm>>, TError = ApiError>(
95
+ options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData>> & Pick<
96
+ DefinedInitialDataOptions<
97
+ Awaited<ReturnType<typeof getLlm>>,
98
+ TError,
99
+ Awaited<ReturnType<typeof getLlm>>
100
+ > , 'initialData'
101
+ >, request?: SecondParameter<typeof fetchApi>}
102
+ , queryClient?: QueryClient
103
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
104
+ export function useGetLlm<TData = Awaited<ReturnType<typeof getLlm>>, TError = ApiError>(
105
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData>> & Pick<
106
+ UndefinedInitialDataOptions<
107
+ Awaited<ReturnType<typeof getLlm>>,
108
+ TError,
109
+ Awaited<ReturnType<typeof getLlm>>
110
+ > , 'initialData'
111
+ >, request?: SecondParameter<typeof fetchApi>}
112
+ , queryClient?: QueryClient
113
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
114
+ export function useGetLlm<TData = Awaited<ReturnType<typeof getLlm>>, TError = ApiError>(
115
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
116
+ , queryClient?: QueryClient
117
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
118
+
119
+ export function useGetLlm<TData = Awaited<ReturnType<typeof getLlm>>, TError = ApiError>(
120
+ options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getLlm>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
121
+ , queryClient?: QueryClient
122
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
123
+
124
+ const queryOptions = getGetLlmQueryOptions(options)
125
+
126
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
127
+
128
+ return { ...query, queryKey: queryOptions.queryKey };
129
+ }
130
+
131
+
132
+
133
+
134
+ export const getLoadLlmUrl = () => {
135
+
136
+
137
+
138
+
139
+ return `/api/v1/llm`
140
+ }
141
+
142
+ export const loadLlm = async (llmLoadRequest: LlmLoadRequest, options?: RequestInit): Promise<LlmState> => {
143
+
144
+ return fetchApi<LlmState>(getLoadLlmUrl(),
145
+ {
146
+ ...options,
147
+ method: 'PUT',
148
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
149
+ body: JSON.stringify(
150
+ llmLoadRequest,)
151
+ }
152
+ );}
153
+
154
+
155
+
156
+
157
+ export const getLoadLlmMutationOptions = <TError = ApiError,
158
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof loadLlm>>, TError,{data: LlmLoadRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
159
+ ): UseMutationOptions<Awaited<ReturnType<typeof loadLlm>>, TError,{data: LlmLoadRequest}, TContext> => {
160
+
161
+ const mutationKey = ['loadLlm'];
162
+ const {mutation: mutationOptions, request: requestOptions} = options ?
163
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
164
+ options
165
+ : {...options, mutation: {...options.mutation, mutationKey}}
166
+ : {mutation: { mutationKey, }, request: undefined};
167
+
168
+
169
+
170
+
171
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof loadLlm>>, {data: LlmLoadRequest}> = (props) => {
172
+ const {data} = props ?? {};
173
+
174
+ return loadLlm(data,requestOptions)
175
+ }
176
+
177
+
178
+
179
+
180
+
181
+
182
+ return { mutationFn, ...mutationOptions }}
183
+
184
+ export type LoadLlmMutationResult = NonNullable<Awaited<ReturnType<typeof loadLlm>>>
185
+ export type LoadLlmMutationBody = LlmLoadRequest
186
+ export type LoadLlmMutationError = ApiError
187
+
188
+ export const useLoadLlm = <TError = ApiError,
189
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof loadLlm>>, TError,{data: LlmLoadRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
190
+ , queryClient?: QueryClient): UseMutationResult<
191
+ Awaited<ReturnType<typeof loadLlm>>,
192
+ TError,
193
+ {data: LlmLoadRequest},
194
+ TContext
195
+ > => {
196
+ return useMutation(getLoadLlmMutationOptions(options), queryClient);
197
+ }
198
+ export const getUnloadLlmUrl = () => {
199
+
200
+
201
+
202
+
203
+ return `/api/v1/llm`
204
+ }
205
+
206
+ export const unloadLlm = async ( options?: RequestInit): Promise<LlmState> => {
207
+
208
+ return fetchApi<LlmState>(getUnloadLlmUrl(),
209
+ {
210
+ ...options,
211
+ method: 'DELETE'
212
+
213
+
214
+ }
215
+ );}
216
+
217
+
218
+
219
+
220
+ export const getUnloadLlmMutationOptions = <TError = ApiError,
221
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof unloadLlm>>, TError,void, TContext>, request?: SecondParameter<typeof fetchApi>}
222
+ ): UseMutationOptions<Awaited<ReturnType<typeof unloadLlm>>, TError,void, TContext> => {
223
+
224
+ const mutationKey = ['unloadLlm'];
225
+ const {mutation: mutationOptions, request: requestOptions} = options ?
226
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
227
+ options
228
+ : {...options, mutation: {...options.mutation, mutationKey}}
229
+ : {mutation: { mutationKey, }, request: undefined};
230
+
231
+
232
+
233
+
234
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof unloadLlm>>, void> = () => {
235
+
236
+
237
+ return unloadLlm(requestOptions)
238
+ }
239
+
240
+
241
+
242
+
243
+
244
+
245
+ return { mutationFn, ...mutationOptions }}
246
+
247
+ export type UnloadLlmMutationResult = NonNullable<Awaited<ReturnType<typeof unloadLlm>>>
248
+
249
+ export type UnloadLlmMutationError = ApiError
250
+
251
+ export const useUnloadLlm = <TError = ApiError,
252
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof unloadLlm>>, TError,void, TContext>, request?: SecondParameter<typeof fetchApi>}
253
+ , queryClient?: QueryClient): UseMutationResult<
254
+ Awaited<ReturnType<typeof unloadLlm>>,
255
+ TError,
256
+ void,
257
+ TContext
258
+ > => {
259
+ return useMutation(getUnloadLlmMutationOptions(options), queryClient);
260
+ }
261
+ export const getCheckLlmHealthUrl = () => {
262
+
263
+
264
+
265
+
266
+ return `/api/v1/llm/health`
267
+ }
268
+
269
+ export const checkLlmHealth = async (llmPingRequest: LlmPingRequest, options?: RequestInit): Promise<LlmPingResponse> => {
270
+
271
+ return fetchApi<LlmPingResponse>(getCheckLlmHealthUrl(),
272
+ {
273
+ ...options,
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
276
+ body: JSON.stringify(
277
+ llmPingRequest,)
278
+ }
279
+ );}
280
+
281
+
282
+
283
+
284
+ export const getCheckLlmHealthMutationOptions = <TError = unknown,
285
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof checkLlmHealth>>, TError,{data: LlmPingRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
286
+ ): UseMutationOptions<Awaited<ReturnType<typeof checkLlmHealth>>, TError,{data: LlmPingRequest}, TContext> => {
287
+
288
+ const mutationKey = ['checkLlmHealth'];
289
+ const {mutation: mutationOptions, request: requestOptions} = options ?
290
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
291
+ options
292
+ : {...options, mutation: {...options.mutation, mutationKey}}
293
+ : {mutation: { mutationKey, }, request: undefined};
294
+
295
+
296
+
297
+
298
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof checkLlmHealth>>, {data: LlmPingRequest}> = (props) => {
299
+ const {data} = props ?? {};
300
+
301
+ return checkLlmHealth(data,requestOptions)
302
+ }
303
+
304
+
305
+
306
+
307
+
308
+
309
+ return { mutationFn, ...mutationOptions }}
310
+
311
+ export type CheckLlmHealthMutationResult = NonNullable<Awaited<ReturnType<typeof checkLlmHealth>>>
312
+ export type CheckLlmHealthMutationBody = LlmPingRequest
313
+ export type CheckLlmHealthMutationError = unknown
314
+
315
+ export const useCheckLlmHealth = <TError = unknown,
316
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof checkLlmHealth>>, TError,{data: LlmPingRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
317
+ , queryClient?: QueryClient): UseMutationResult<
318
+ Awaited<ReturnType<typeof checkLlmHealth>>,
319
+ TError,
320
+ {data: LlmPingRequest},
321
+ TContext
322
+ > => {
323
+ return useMutation(getCheckLlmHealthMutationOptions(options), queryClient);
324
+ }
325
+ export const getListLlmModelsUrl = (params?: ListLlmModelsParams,) => {
326
+ const normalizedParams = new URLSearchParams();
327
+
328
+ Object.entries(params || {}).forEach(([key, value]) => {
329
+
330
+ if (value !== undefined) {
331
+ normalizedParams.append(key, value === null ? 'null' : value.toString())
332
+ }
333
+ });
334
+
335
+ const stringifiedParams = normalizedParams.toString();
336
+
337
+ return stringifiedParams.length > 0 ? `/api/v1/llm/models?${stringifiedParams}` : `/api/v1/llm/models`
338
+ }
339
+
340
+ export const listLlmModels = async (params?: ListLlmModelsParams, options?: RequestInit): Promise<LlmModelInfo[]> => {
341
+
342
+ return fetchApi<LlmModelInfo[]>(getListLlmModelsUrl(params),
343
+ {
344
+ ...options,
345
+ method: 'GET'
346
+
347
+
348
+ }
349
+ );}
350
+
351
+
352
+
353
+
354
+
355
+ export const getListLlmModelsQueryKey = (params?: ListLlmModelsParams,) => {
356
+ return [
357
+ `/api/v1/llm/models`, ...(params ? [params] : [])
358
+ ] as const;
359
+ }
360
+
361
+
362
+ export const getListLlmModelsQueryOptions = <TData = Awaited<ReturnType<typeof listLlmModels>>, TError = ApiError>(params?: ListLlmModelsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
363
+ ) => {
364
+
365
+ const {query: queryOptions, request: requestOptions} = options ?? {};
366
+
367
+ const queryKey = queryOptions?.queryKey ?? getListLlmModelsQueryKey(params);
368
+
369
+
370
+
371
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof listLlmModels>>> = ({ signal }) => listLlmModels(params, { signal, ...requestOptions });
372
+
373
+
374
+
375
+
376
+
377
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
378
+ }
379
+
380
+ export type ListLlmModelsQueryResult = NonNullable<Awaited<ReturnType<typeof listLlmModels>>>
381
+ export type ListLlmModelsQueryError = ApiError
382
+
383
+
384
+ export function useListLlmModels<TData = Awaited<ReturnType<typeof listLlmModels>>, TError = ApiError>(
385
+ params: undefined | ListLlmModelsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData>> & Pick<
386
+ DefinedInitialDataOptions<
387
+ Awaited<ReturnType<typeof listLlmModels>>,
388
+ TError,
389
+ Awaited<ReturnType<typeof listLlmModels>>
390
+ > , 'initialData'
391
+ >, request?: SecondParameter<typeof fetchApi>}
392
+ , queryClient?: QueryClient
393
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
394
+ export function useListLlmModels<TData = Awaited<ReturnType<typeof listLlmModels>>, TError = ApiError>(
395
+ params?: ListLlmModelsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData>> & Pick<
396
+ UndefinedInitialDataOptions<
397
+ Awaited<ReturnType<typeof listLlmModels>>,
398
+ TError,
399
+ Awaited<ReturnType<typeof listLlmModels>>
400
+ > , 'initialData'
401
+ >, request?: SecondParameter<typeof fetchApi>}
402
+ , queryClient?: QueryClient
403
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
404
+ export function useListLlmModels<TData = Awaited<ReturnType<typeof listLlmModels>>, TError = ApiError>(
405
+ params?: ListLlmModelsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
406
+ , queryClient?: QueryClient
407
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
408
+
409
+ export function useListLlmModels<TData = Awaited<ReturnType<typeof listLlmModels>>, TError = ApiError>(
410
+ params?: ListLlmModelsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listLlmModels>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
411
+ , queryClient?: QueryClient
412
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
413
+
414
+ const queryOptions = getListLlmModelsQueryOptions(params,options)
415
+
416
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
417
+
418
+ return { ...query, queryKey: queryOptions.queryKey };
419
+ }
420
+
421
+
422
+
423
+
ui/lib/api/processing/processing.ts ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation
8
+ } from '@tanstack/react-query';
9
+ import type {
10
+ MutationFunction,
11
+ QueryClient,
12
+ UseMutationOptions,
13
+ UseMutationResult
14
+ } from '@tanstack/react-query';
15
+
16
+ import type {
17
+ ApiError,
18
+ RenderRequest,
19
+ TranslateRequest
20
+ } from '../schemas';
21
+
22
+ import { fetchApi } from '.././fetch';
23
+
24
+
25
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
26
+
27
+
28
+
29
+ export const getDetectDocumentUrl = (documentId: string,) => {
30
+
31
+
32
+
33
+
34
+ return `/api/v1/documents/${documentId}/detect`
35
+ }
36
+
37
+ export const detectDocument = async (documentId: string, options?: RequestInit): Promise<void> => {
38
+
39
+ return fetchApi<void>(getDetectDocumentUrl(documentId),
40
+ {
41
+ ...options,
42
+ method: 'POST'
43
+
44
+
45
+ }
46
+ );}
47
+
48
+
49
+
50
+
51
+ export const getDetectDocumentMutationOptions = <TError = ApiError,
52
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof detectDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
53
+ ): UseMutationOptions<Awaited<ReturnType<typeof detectDocument>>, TError,{documentId: string}, TContext> => {
54
+
55
+ const mutationKey = ['detectDocument'];
56
+ const {mutation: mutationOptions, request: requestOptions} = options ?
57
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
58
+ options
59
+ : {...options, mutation: {...options.mutation, mutationKey}}
60
+ : {mutation: { mutationKey, }, request: undefined};
61
+
62
+
63
+
64
+
65
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof detectDocument>>, {documentId: string}> = (props) => {
66
+ const {documentId} = props ?? {};
67
+
68
+ return detectDocument(documentId,requestOptions)
69
+ }
70
+
71
+
72
+
73
+
74
+
75
+
76
+ return { mutationFn, ...mutationOptions }}
77
+
78
+ export type DetectDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof detectDocument>>>
79
+
80
+ export type DetectDocumentMutationError = ApiError
81
+
82
+ export const useDetectDocument = <TError = ApiError,
83
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof detectDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
84
+ , queryClient?: QueryClient): UseMutationResult<
85
+ Awaited<ReturnType<typeof detectDocument>>,
86
+ TError,
87
+ {documentId: string},
88
+ TContext
89
+ > => {
90
+ return useMutation(getDetectDocumentMutationOptions(options), queryClient);
91
+ }
92
+ export const getInpaintDocumentUrl = (documentId: string,) => {
93
+
94
+
95
+
96
+
97
+ return `/api/v1/documents/${documentId}/inpaint`
98
+ }
99
+
100
+ export const inpaintDocument = async (documentId: string, options?: RequestInit): Promise<void> => {
101
+
102
+ return fetchApi<void>(getInpaintDocumentUrl(documentId),
103
+ {
104
+ ...options,
105
+ method: 'POST'
106
+
107
+
108
+ }
109
+ );}
110
+
111
+
112
+
113
+
114
+ export const getInpaintDocumentMutationOptions = <TError = ApiError,
115
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof inpaintDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
116
+ ): UseMutationOptions<Awaited<ReturnType<typeof inpaintDocument>>, TError,{documentId: string}, TContext> => {
117
+
118
+ const mutationKey = ['inpaintDocument'];
119
+ const {mutation: mutationOptions, request: requestOptions} = options ?
120
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
121
+ options
122
+ : {...options, mutation: {...options.mutation, mutationKey}}
123
+ : {mutation: { mutationKey, }, request: undefined};
124
+
125
+
126
+
127
+
128
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof inpaintDocument>>, {documentId: string}> = (props) => {
129
+ const {documentId} = props ?? {};
130
+
131
+ return inpaintDocument(documentId,requestOptions)
132
+ }
133
+
134
+
135
+
136
+
137
+
138
+
139
+ return { mutationFn, ...mutationOptions }}
140
+
141
+ export type InpaintDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof inpaintDocument>>>
142
+
143
+ export type InpaintDocumentMutationError = ApiError
144
+
145
+ export const useInpaintDocument = <TError = ApiError,
146
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof inpaintDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
147
+ , queryClient?: QueryClient): UseMutationResult<
148
+ Awaited<ReturnType<typeof inpaintDocument>>,
149
+ TError,
150
+ {documentId: string},
151
+ TContext
152
+ > => {
153
+ return useMutation(getInpaintDocumentMutationOptions(options), queryClient);
154
+ }
155
+ export const getRecognizeDocumentUrl = (documentId: string,) => {
156
+
157
+
158
+
159
+
160
+ return `/api/v1/documents/${documentId}/recognize`
161
+ }
162
+
163
+ export const recognizeDocument = async (documentId: string, options?: RequestInit): Promise<void> => {
164
+
165
+ return fetchApi<void>(getRecognizeDocumentUrl(documentId),
166
+ {
167
+ ...options,
168
+ method: 'POST'
169
+
170
+
171
+ }
172
+ );}
173
+
174
+
175
+
176
+
177
+ export const getRecognizeDocumentMutationOptions = <TError = ApiError,
178
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof recognizeDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
179
+ ): UseMutationOptions<Awaited<ReturnType<typeof recognizeDocument>>, TError,{documentId: string}, TContext> => {
180
+
181
+ const mutationKey = ['recognizeDocument'];
182
+ const {mutation: mutationOptions, request: requestOptions} = options ?
183
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
184
+ options
185
+ : {...options, mutation: {...options.mutation, mutationKey}}
186
+ : {mutation: { mutationKey, }, request: undefined};
187
+
188
+
189
+
190
+
191
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof recognizeDocument>>, {documentId: string}> = (props) => {
192
+ const {documentId} = props ?? {};
193
+
194
+ return recognizeDocument(documentId,requestOptions)
195
+ }
196
+
197
+
198
+
199
+
200
+
201
+
202
+ return { mutationFn, ...mutationOptions }}
203
+
204
+ export type RecognizeDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof recognizeDocument>>>
205
+
206
+ export type RecognizeDocumentMutationError = ApiError
207
+
208
+ export const useRecognizeDocument = <TError = ApiError,
209
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof recognizeDocument>>, TError,{documentId: string}, TContext>, request?: SecondParameter<typeof fetchApi>}
210
+ , queryClient?: QueryClient): UseMutationResult<
211
+ Awaited<ReturnType<typeof recognizeDocument>>,
212
+ TError,
213
+ {documentId: string},
214
+ TContext
215
+ > => {
216
+ return useMutation(getRecognizeDocumentMutationOptions(options), queryClient);
217
+ }
218
+ export const getRenderDocumentUrl = (documentId: string,) => {
219
+
220
+
221
+
222
+
223
+ return `/api/v1/documents/${documentId}/render`
224
+ }
225
+
226
+ export const renderDocument = async (documentId: string,
227
+ renderRequest: RenderRequest, options?: RequestInit): Promise<void> => {
228
+
229
+ return fetchApi<void>(getRenderDocumentUrl(documentId),
230
+ {
231
+ ...options,
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
234
+ body: JSON.stringify(
235
+ renderRequest,)
236
+ }
237
+ );}
238
+
239
+
240
+
241
+
242
+ export const getRenderDocumentMutationOptions = <TError = ApiError,
243
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof renderDocument>>, TError,{documentId: string;data: RenderRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
244
+ ): UseMutationOptions<Awaited<ReturnType<typeof renderDocument>>, TError,{documentId: string;data: RenderRequest}, TContext> => {
245
+
246
+ const mutationKey = ['renderDocument'];
247
+ const {mutation: mutationOptions, request: requestOptions} = options ?
248
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
249
+ options
250
+ : {...options, mutation: {...options.mutation, mutationKey}}
251
+ : {mutation: { mutationKey, }, request: undefined};
252
+
253
+
254
+
255
+
256
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof renderDocument>>, {documentId: string;data: RenderRequest}> = (props) => {
257
+ const {documentId,data} = props ?? {};
258
+
259
+ return renderDocument(documentId,data,requestOptions)
260
+ }
261
+
262
+
263
+
264
+
265
+
266
+
267
+ return { mutationFn, ...mutationOptions }}
268
+
269
+ export type RenderDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof renderDocument>>>
270
+ export type RenderDocumentMutationBody = RenderRequest
271
+ export type RenderDocumentMutationError = ApiError
272
+
273
+ export const useRenderDocument = <TError = ApiError,
274
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof renderDocument>>, TError,{documentId: string;data: RenderRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
275
+ , queryClient?: QueryClient): UseMutationResult<
276
+ Awaited<ReturnType<typeof renderDocument>>,
277
+ TError,
278
+ {documentId: string;data: RenderRequest},
279
+ TContext
280
+ > => {
281
+ return useMutation(getRenderDocumentMutationOptions(options), queryClient);
282
+ }
283
+ export const getTranslateDocumentUrl = (documentId: string,) => {
284
+
285
+
286
+
287
+
288
+ return `/api/v1/documents/${documentId}/translate`
289
+ }
290
+
291
+ export const translateDocument = async (documentId: string,
292
+ translateRequest: TranslateRequest, options?: RequestInit): Promise<void> => {
293
+
294
+ return fetchApi<void>(getTranslateDocumentUrl(documentId),
295
+ {
296
+ ...options,
297
+ method: 'POST',
298
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
299
+ body: JSON.stringify(
300
+ translateRequest,)
301
+ }
302
+ );}
303
+
304
+
305
+
306
+
307
+ export const getTranslateDocumentMutationOptions = <TError = ApiError,
308
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof translateDocument>>, TError,{documentId: string;data: TranslateRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
309
+ ): UseMutationOptions<Awaited<ReturnType<typeof translateDocument>>, TError,{documentId: string;data: TranslateRequest}, TContext> => {
310
+
311
+ const mutationKey = ['translateDocument'];
312
+ const {mutation: mutationOptions, request: requestOptions} = options ?
313
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
314
+ options
315
+ : {...options, mutation: {...options.mutation, mutationKey}}
316
+ : {mutation: { mutationKey, }, request: undefined};
317
+
318
+
319
+
320
+
321
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof translateDocument>>, {documentId: string;data: TranslateRequest}> = (props) => {
322
+ const {documentId,data} = props ?? {};
323
+
324
+ return translateDocument(documentId,data,requestOptions)
325
+ }
326
+
327
+
328
+
329
+
330
+
331
+
332
+ return { mutationFn, ...mutationOptions }}
333
+
334
+ export type TranslateDocumentMutationResult = NonNullable<Awaited<ReturnType<typeof translateDocument>>>
335
+ export type TranslateDocumentMutationBody = TranslateRequest
336
+ export type TranslateDocumentMutationError = ApiError
337
+
338
+ export const useTranslateDocument = <TError = ApiError,
339
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof translateDocument>>, TError,{documentId: string;data: TranslateRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
340
+ , queryClient?: QueryClient): UseMutationResult<
341
+ Awaited<ReturnType<typeof translateDocument>>,
342
+ TError,
343
+ {documentId: string;data: TranslateRequest},
344
+ TContext
345
+ > => {
346
+ return useMutation(getTranslateDocumentMutationOptions(options), queryClient);
347
+ }
ui/lib/api/providers/providers.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation,
8
+ useQuery
9
+ } from '@tanstack/react-query';
10
+ import type {
11
+ DataTag,
12
+ DefinedInitialDataOptions,
13
+ DefinedUseQueryResult,
14
+ MutationFunction,
15
+ QueryClient,
16
+ QueryFunction,
17
+ QueryKey,
18
+ UndefinedInitialDataOptions,
19
+ UseMutationOptions,
20
+ UseMutationResult,
21
+ UseQueryOptions,
22
+ UseQueryResult
23
+ } from '@tanstack/react-query';
24
+
25
+ import type {
26
+ ApiError,
27
+ ApiKeyResponse,
28
+ ApiKeyValue
29
+ } from '../schemas';
30
+
31
+ import { fetchApi } from '.././fetch';
32
+
33
+
34
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
35
+
36
+
37
+
38
+ export const getGetApiKeyUrl = (provider: string,) => {
39
+
40
+
41
+
42
+
43
+ return `/api/v1/providers/${provider}/api-key`
44
+ }
45
+
46
+ export const getApiKey = async (provider: string, options?: RequestInit): Promise<ApiKeyResponse> => {
47
+
48
+ return fetchApi<ApiKeyResponse>(getGetApiKeyUrl(provider),
49
+ {
50
+ ...options,
51
+ method: 'GET'
52
+
53
+
54
+ }
55
+ );}
56
+
57
+
58
+
59
+
60
+
61
+ export const getGetApiKeyQueryKey = (provider: string,) => {
62
+ return [
63
+ `/api/v1/providers/${provider}/api-key`
64
+ ] as const;
65
+ }
66
+
67
+
68
+ export const getGetApiKeyQueryOptions = <TData = Awaited<ReturnType<typeof getApiKey>>, TError = ApiError>(provider: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
69
+ ) => {
70
+
71
+ const {query: queryOptions, request: requestOptions} = options ?? {};
72
+
73
+ const queryKey = queryOptions?.queryKey ?? getGetApiKeyQueryKey(provider);
74
+
75
+
76
+
77
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiKey>>> = ({ signal }) => getApiKey(provider, { signal, ...requestOptions });
78
+
79
+
80
+
81
+
82
+
83
+ return { queryKey, queryFn, enabled: !!(provider), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
84
+ }
85
+
86
+ export type GetApiKeyQueryResult = NonNullable<Awaited<ReturnType<typeof getApiKey>>>
87
+ export type GetApiKeyQueryError = ApiError
88
+
89
+
90
+ export function useGetApiKey<TData = Awaited<ReturnType<typeof getApiKey>>, TError = ApiError>(
91
+ provider: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData>> & Pick<
92
+ DefinedInitialDataOptions<
93
+ Awaited<ReturnType<typeof getApiKey>>,
94
+ TError,
95
+ Awaited<ReturnType<typeof getApiKey>>
96
+ > , 'initialData'
97
+ >, request?: SecondParameter<typeof fetchApi>}
98
+ , queryClient?: QueryClient
99
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
100
+ export function useGetApiKey<TData = Awaited<ReturnType<typeof getApiKey>>, TError = ApiError>(
101
+ provider: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData>> & Pick<
102
+ UndefinedInitialDataOptions<
103
+ Awaited<ReturnType<typeof getApiKey>>,
104
+ TError,
105
+ Awaited<ReturnType<typeof getApiKey>>
106
+ > , 'initialData'
107
+ >, request?: SecondParameter<typeof fetchApi>}
108
+ , queryClient?: QueryClient
109
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
110
+ export function useGetApiKey<TData = Awaited<ReturnType<typeof getApiKey>>, TError = ApiError>(
111
+ provider: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
112
+ , queryClient?: QueryClient
113
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
114
+
115
+ export function useGetApiKey<TData = Awaited<ReturnType<typeof getApiKey>>, TError = ApiError>(
116
+ provider: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiKey>>, TError, TData>>, request?: SecondParameter<typeof fetchApi>}
117
+ , queryClient?: QueryClient
118
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
119
+
120
+ const queryOptions = getGetApiKeyQueryOptions(provider,options)
121
+
122
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
123
+
124
+ return { ...query, queryKey: queryOptions.queryKey };
125
+ }
126
+
127
+
128
+
129
+
130
+ export const getSetApiKeyUrl = (provider: string,) => {
131
+
132
+
133
+
134
+
135
+ return `/api/v1/providers/${provider}/api-key`
136
+ }
137
+
138
+ export const setApiKey = async (provider: string,
139
+ apiKeyValue: ApiKeyValue, options?: RequestInit): Promise<void> => {
140
+
141
+ return fetchApi<void>(getSetApiKeyUrl(provider),
142
+ {
143
+ ...options,
144
+ method: 'PUT',
145
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
146
+ body: JSON.stringify(
147
+ apiKeyValue,)
148
+ }
149
+ );}
150
+
151
+
152
+
153
+
154
+ export const getSetApiKeyMutationOptions = <TError = ApiError,
155
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setApiKey>>, TError,{provider: string;data: ApiKeyValue}, TContext>, request?: SecondParameter<typeof fetchApi>}
156
+ ): UseMutationOptions<Awaited<ReturnType<typeof setApiKey>>, TError,{provider: string;data: ApiKeyValue}, TContext> => {
157
+
158
+ const mutationKey = ['setApiKey'];
159
+ const {mutation: mutationOptions, request: requestOptions} = options ?
160
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
161
+ options
162
+ : {...options, mutation: {...options.mutation, mutationKey}}
163
+ : {mutation: { mutationKey, }, request: undefined};
164
+
165
+
166
+
167
+
168
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof setApiKey>>, {provider: string;data: ApiKeyValue}> = (props) => {
169
+ const {provider,data} = props ?? {};
170
+
171
+ return setApiKey(provider,data,requestOptions)
172
+ }
173
+
174
+
175
+
176
+
177
+
178
+
179
+ return { mutationFn, ...mutationOptions }}
180
+
181
+ export type SetApiKeyMutationResult = NonNullable<Awaited<ReturnType<typeof setApiKey>>>
182
+ export type SetApiKeyMutationBody = ApiKeyValue
183
+ export type SetApiKeyMutationError = ApiError
184
+
185
+ export const useSetApiKey = <TError = ApiError,
186
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setApiKey>>, TError,{provider: string;data: ApiKeyValue}, TContext>, request?: SecondParameter<typeof fetchApi>}
187
+ , queryClient?: QueryClient): UseMutationResult<
188
+ Awaited<ReturnType<typeof setApiKey>>,
189
+ TError,
190
+ {provider: string;data: ApiKeyValue},
191
+ TContext
192
+ > => {
193
+ return useMutation(getSetApiKeyMutationOptions(options), queryClient);
194
+ }
ui/lib/api/regions/regions.ts ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import {
7
+ useMutation
8
+ } from '@tanstack/react-query';
9
+ import type {
10
+ MutationFunction,
11
+ QueryClient,
12
+ UseMutationOptions,
13
+ UseMutationResult
14
+ } from '@tanstack/react-query';
15
+
16
+ import type {
17
+ ApiError,
18
+ BrushRegionRequest,
19
+ InpaintRegionRequest,
20
+ MaskRegionRequest
21
+ } from '../schemas';
22
+
23
+ import { fetchApi } from '.././fetch';
24
+
25
+
26
+ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
27
+
28
+
29
+
30
+ export const getUpdateBrushLayerUrl = (documentId: string,) => {
31
+
32
+
33
+
34
+
35
+ return `/api/v1/documents/${documentId}/brush-layer`
36
+ }
37
+
38
+ export const updateBrushLayer = async (documentId: string,
39
+ brushRegionRequest: BrushRegionRequest, options?: RequestInit): Promise<void> => {
40
+
41
+ return fetchApi<void>(getUpdateBrushLayerUrl(documentId),
42
+ {
43
+ ...options,
44
+ method: 'PUT',
45
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
46
+ body: JSON.stringify(
47
+ brushRegionRequest,)
48
+ }
49
+ );}
50
+
51
+
52
+
53
+
54
+ export const getUpdateBrushLayerMutationOptions = <TError = ApiError,
55
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateBrushLayer>>, TError,{documentId: string;data: BrushRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
56
+ ): UseMutationOptions<Awaited<ReturnType<typeof updateBrushLayer>>, TError,{documentId: string;data: BrushRegionRequest}, TContext> => {
57
+
58
+ const mutationKey = ['updateBrushLayer'];
59
+ const {mutation: mutationOptions, request: requestOptions} = options ?
60
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
61
+ options
62
+ : {...options, mutation: {...options.mutation, mutationKey}}
63
+ : {mutation: { mutationKey, }, request: undefined};
64
+
65
+
66
+
67
+
68
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateBrushLayer>>, {documentId: string;data: BrushRegionRequest}> = (props) => {
69
+ const {documentId,data} = props ?? {};
70
+
71
+ return updateBrushLayer(documentId,data,requestOptions)
72
+ }
73
+
74
+
75
+
76
+
77
+
78
+
79
+ return { mutationFn, ...mutationOptions }}
80
+
81
+ export type UpdateBrushLayerMutationResult = NonNullable<Awaited<ReturnType<typeof updateBrushLayer>>>
82
+ export type UpdateBrushLayerMutationBody = BrushRegionRequest
83
+ export type UpdateBrushLayerMutationError = ApiError
84
+
85
+ export const useUpdateBrushLayer = <TError = ApiError,
86
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateBrushLayer>>, TError,{documentId: string;data: BrushRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
87
+ , queryClient?: QueryClient): UseMutationResult<
88
+ Awaited<ReturnType<typeof updateBrushLayer>>,
89
+ TError,
90
+ {documentId: string;data: BrushRegionRequest},
91
+ TContext
92
+ > => {
93
+ return useMutation(getUpdateBrushLayerMutationOptions(options), queryClient);
94
+ }
95
+ export const getInpaintRegionUrl = (documentId: string,) => {
96
+
97
+
98
+
99
+
100
+ return `/api/v1/documents/${documentId}/inpaint-region`
101
+ }
102
+
103
+ export const inpaintRegion = async (documentId: string,
104
+ inpaintRegionRequest: InpaintRegionRequest, options?: RequestInit): Promise<void> => {
105
+
106
+ return fetchApi<void>(getInpaintRegionUrl(documentId),
107
+ {
108
+ ...options,
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
111
+ body: JSON.stringify(
112
+ inpaintRegionRequest,)
113
+ }
114
+ );}
115
+
116
+
117
+
118
+
119
+ export const getInpaintRegionMutationOptions = <TError = ApiError,
120
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof inpaintRegion>>, TError,{documentId: string;data: InpaintRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
121
+ ): UseMutationOptions<Awaited<ReturnType<typeof inpaintRegion>>, TError,{documentId: string;data: InpaintRegionRequest}, TContext> => {
122
+
123
+ const mutationKey = ['inpaintRegion'];
124
+ const {mutation: mutationOptions, request: requestOptions} = options ?
125
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
126
+ options
127
+ : {...options, mutation: {...options.mutation, mutationKey}}
128
+ : {mutation: { mutationKey, }, request: undefined};
129
+
130
+
131
+
132
+
133
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof inpaintRegion>>, {documentId: string;data: InpaintRegionRequest}> = (props) => {
134
+ const {documentId,data} = props ?? {};
135
+
136
+ return inpaintRegion(documentId,data,requestOptions)
137
+ }
138
+
139
+
140
+
141
+
142
+
143
+
144
+ return { mutationFn, ...mutationOptions }}
145
+
146
+ export type InpaintRegionMutationResult = NonNullable<Awaited<ReturnType<typeof inpaintRegion>>>
147
+ export type InpaintRegionMutationBody = InpaintRegionRequest
148
+ export type InpaintRegionMutationError = ApiError
149
+
150
+ export const useInpaintRegion = <TError = ApiError,
151
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof inpaintRegion>>, TError,{documentId: string;data: InpaintRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
152
+ , queryClient?: QueryClient): UseMutationResult<
153
+ Awaited<ReturnType<typeof inpaintRegion>>,
154
+ TError,
155
+ {documentId: string;data: InpaintRegionRequest},
156
+ TContext
157
+ > => {
158
+ return useMutation(getInpaintRegionMutationOptions(options), queryClient);
159
+ }
160
+ export const getUpdateMaskUrl = (documentId: string,) => {
161
+
162
+
163
+
164
+
165
+ return `/api/v1/documents/${documentId}/mask`
166
+ }
167
+
168
+ export const updateMask = async (documentId: string,
169
+ maskRegionRequest: MaskRegionRequest, options?: RequestInit): Promise<void> => {
170
+
171
+ return fetchApi<void>(getUpdateMaskUrl(documentId),
172
+ {
173
+ ...options,
174
+ method: 'PUT',
175
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
176
+ body: JSON.stringify(
177
+ maskRegionRequest,)
178
+ }
179
+ );}
180
+
181
+
182
+
183
+
184
+ export const getUpdateMaskMutationOptions = <TError = ApiError,
185
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMask>>, TError,{documentId: string;data: MaskRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
186
+ ): UseMutationOptions<Awaited<ReturnType<typeof updateMask>>, TError,{documentId: string;data: MaskRegionRequest}, TContext> => {
187
+
188
+ const mutationKey = ['updateMask'];
189
+ const {mutation: mutationOptions, request: requestOptions} = options ?
190
+ options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
191
+ options
192
+ : {...options, mutation: {...options.mutation, mutationKey}}
193
+ : {mutation: { mutationKey, }, request: undefined};
194
+
195
+
196
+
197
+
198
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMask>>, {documentId: string;data: MaskRegionRequest}> = (props) => {
199
+ const {documentId,data} = props ?? {};
200
+
201
+ return updateMask(documentId,data,requestOptions)
202
+ }
203
+
204
+
205
+
206
+
207
+
208
+
209
+ return { mutationFn, ...mutationOptions }}
210
+
211
+ export type UpdateMaskMutationResult = NonNullable<Awaited<ReturnType<typeof updateMask>>>
212
+ export type UpdateMaskMutationBody = MaskRegionRequest
213
+ export type UpdateMaskMutationError = ApiError
214
+
215
+ export const useUpdateMask = <TError = ApiError,
216
+ TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMask>>, TError,{documentId: string;data: MaskRegionRequest}, TContext>, request?: SecondParameter<typeof fetchApi>}
217
+ , queryClient?: QueryClient): UseMutationResult<
218
+ Awaited<ReturnType<typeof updateMask>>,
219
+ TError,
220
+ {documentId: string;data: MaskRegionRequest},
221
+ TContext
222
+ > => {
223
+ return useMutation(getUpdateMaskMutationOptions(options), queryClient);
224
+ }
ui/lib/api/schemas/apiError.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface ApiError {
8
+ message: string;
9
+ /** @minimum 0 */
10
+ status: number;
11
+ }
ui/lib/api/schemas/apiKeyResponse.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.6.2 🍺
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface ApiKeyResponse {
8
+ /** @nullable */
9
+ apiKey?: string | null;
10
+ }