Mayo commited on
Commit
bef4e25
·
unverified ·
1 Parent(s): c86f464

feat: websocket-based rpc

Browse files
Cargo.lock CHANGED
@@ -546,6 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
546
  checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
547
  dependencies = [
548
  "axum-core",
 
549
  "bytes",
550
  "form_urlencoded",
551
  "futures-util",
@@ -558,15 +559,16 @@ dependencies = [
558
  "matchit",
559
  "memchr",
560
  "mime",
561
- "multer",
562
  "percent-encoding",
563
  "pin-project-lite",
564
  "serde_core",
565
  "serde_json",
566
  "serde_path_to_error",
567
  "serde_urlencoded",
 
568
  "sync_wrapper",
569
  "tokio",
 
570
  "tower",
571
  "tower-layer",
572
  "tower-service",
@@ -1564,6 +1566,12 @@ dependencies = [
1564
  "serde",
1565
  ]
1566
 
 
 
 
 
 
 
1567
  [[package]]
1568
  name = "deranged"
1569
  version = "0.5.5"
@@ -1870,6 +1878,18 @@ dependencies = [
1870
  "syn 2.0.114",
1871
  ]
1872
 
 
 
 
 
 
 
 
 
 
 
 
 
1873
  [[package]]
1874
  name = "enumflags2"
1875
  version = "0.7.12"
@@ -4059,10 +4079,12 @@ name = "koharu"
4059
  version = "0.30.1"
4060
  dependencies = [
4061
  "anyhow",
 
4062
  "axum",
4063
  "blake3",
4064
  "clap",
4065
  "dirs",
 
4066
  "futures",
4067
  "git-version",
4068
  "icu",
@@ -4076,7 +4098,8 @@ dependencies = [
4076
  "postcard",
4077
  "rayon",
4078
  "rfd",
4079
- "rust-embed",
 
4080
  "serde",
4081
  "serde_bytes",
4082
  "strum",
@@ -4085,6 +4108,7 @@ dependencies = [
4085
  "tauri-build",
4086
  "thiserror 2.0.18",
4087
  "tokio",
 
4088
  "tower-http",
4089
  "tracing",
4090
  "tracing-subscriber",
@@ -4630,23 +4654,6 @@ dependencies = [
4630
  "windows-sys 0.60.2",
4631
  ]
4632
 
4633
- [[package]]
4634
- name = "multer"
4635
- version = "3.1.0"
4636
- source = "registry+https://github.com/rust-lang/crates.io-index"
4637
- checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
4638
- dependencies = [
4639
- "bytes",
4640
- "encoding_rs",
4641
- "futures-util",
4642
- "http",
4643
- "httparse",
4644
- "memchr",
4645
- "mime",
4646
- "spin",
4647
- "version_check",
4648
- ]
4649
-
4650
  [[package]]
4651
  name = "naga"
4652
  version = "28.0.0"
@@ -6373,48 +6380,42 @@ dependencies = [
6373
  ]
6374
 
6375
  [[package]]
6376
- name = "roxmltree"
6377
- version = "0.21.1"
6378
  source = "registry+https://github.com/rust-lang/crates.io-index"
6379
- checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
6380
  dependencies = [
6381
- "memchr",
6382
  ]
6383
 
6384
  [[package]]
6385
- name = "rust-embed"
6386
- version = "8.11.0"
6387
  source = "registry+https://github.com/rust-lang/crates.io-index"
6388
- checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
6389
  dependencies = [
6390
- "rust-embed-impl",
6391
- "rust-embed-utils",
6392
- "walkdir",
6393
  ]
6394
 
6395
  [[package]]
6396
- name = "rust-embed-impl"
6397
- version = "8.11.0"
6398
  source = "registry+https://github.com/rust-lang/crates.io-index"
6399
- checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
6400
  dependencies = [
6401
- "proc-macro2",
6402
- "quote",
6403
- "rust-embed-utils",
6404
- "shellexpand",
6405
- "syn 2.0.114",
6406
- "walkdir",
6407
  ]
6408
 
6409
  [[package]]
6410
- name = "rust-embed-utils"
6411
- version = "8.11.0"
6412
  source = "registry+https://github.com/rust-lang/crates.io-index"
6413
- checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
6414
  dependencies = [
6415
- "mime_guess",
6416
- "sha2",
6417
- "walkdir",
6418
  ]
6419
 
6420
  [[package]]
@@ -6905,15 +6906,6 @@ dependencies = [
6905
  "lazy_static",
6906
  ]
6907
 
6908
- [[package]]
6909
- name = "shellexpand"
6910
- version = "3.1.1"
6911
- source = "registry+https://github.com/rust-lang/crates.io-index"
6912
- checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
6913
- dependencies = [
6914
- "dirs",
6915
- ]
6916
-
6917
  [[package]]
6918
  name = "shlex"
6919
  version = "1.3.0"
@@ -7793,6 +7785,18 @@ dependencies = [
7793
  "tokio",
7794
  ]
7795
 
 
 
 
 
 
 
 
 
 
 
 
 
7796
  [[package]]
7797
  name = "tokio-util"
7798
  version = "0.7.18"
@@ -8070,6 +8074,23 @@ version = "0.25.1"
8070
  source = "registry+https://github.com/rust-lang/crates.io-index"
8071
  checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
8072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8073
  [[package]]
8074
  name = "typed-path"
8075
  version = "0.12.2"
 
546
  checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
547
  dependencies = [
548
  "axum-core",
549
+ "base64 0.22.1",
550
  "bytes",
551
  "form_urlencoded",
552
  "futures-util",
 
559
  "matchit",
560
  "memchr",
561
  "mime",
 
562
  "percent-encoding",
563
  "pin-project-lite",
564
  "serde_core",
565
  "serde_json",
566
  "serde_path_to_error",
567
  "serde_urlencoded",
568
+ "sha1",
569
  "sync_wrapper",
570
  "tokio",
571
+ "tokio-tungstenite",
572
  "tower",
573
  "tower-layer",
574
  "tower-service",
 
1566
  "serde",
1567
  ]
1568
 
1569
+ [[package]]
1570
+ name = "data-encoding"
1571
+ version = "2.10.0"
1572
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1573
+ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
1574
+
1575
  [[package]]
1576
  name = "deranged"
1577
  version = "0.5.5"
 
1878
  "syn 2.0.114",
1879
  ]
1880
 
1881
+ [[package]]
1882
+ name = "enum_dispatch"
1883
+ version = "0.3.13"
1884
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1885
+ checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
1886
+ dependencies = [
1887
+ "once_cell",
1888
+ "proc-macro2",
1889
+ "quote",
1890
+ "syn 2.0.114",
1891
+ ]
1892
+
1893
  [[package]]
1894
  name = "enumflags2"
1895
  version = "0.7.12"
 
4079
  version = "0.30.1"
4080
  dependencies = [
4081
  "anyhow",
4082
+ "async-trait",
4083
  "axum",
4084
  "blake3",
4085
  "clap",
4086
  "dirs",
4087
+ "enum_dispatch",
4088
  "futures",
4089
  "git-version",
4090
  "icu",
 
4098
  "postcard",
4099
  "rayon",
4100
  "rfd",
4101
+ "rmp-serde",
4102
+ "rmpv",
4103
  "serde",
4104
  "serde_bytes",
4105
  "strum",
 
4108
  "tauri-build",
4109
  "thiserror 2.0.18",
4110
  "tokio",
4111
+ "tower",
4112
  "tower-http",
4113
  "tracing",
4114
  "tracing-subscriber",
 
4654
  "windows-sys 0.60.2",
4655
  ]
4656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4657
  [[package]]
4658
  name = "naga"
4659
  version = "28.0.0"
 
6380
  ]
6381
 
6382
  [[package]]
6383
+ name = "rmp"
6384
+ version = "0.8.15"
6385
  source = "registry+https://github.com/rust-lang/crates.io-index"
6386
+ checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
6387
  dependencies = [
6388
+ "num-traits",
6389
  ]
6390
 
6391
  [[package]]
6392
+ name = "rmp-serde"
6393
+ version = "1.3.1"
6394
  source = "registry+https://github.com/rust-lang/crates.io-index"
6395
+ checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
6396
  dependencies = [
6397
+ "rmp",
6398
+ "serde",
 
6399
  ]
6400
 
6401
  [[package]]
6402
+ name = "rmpv"
6403
+ version = "1.3.1"
6404
  source = "registry+https://github.com/rust-lang/crates.io-index"
6405
+ checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
6406
  dependencies = [
6407
+ "rmp",
6408
+ "serde",
6409
+ "serde_bytes",
 
 
 
6410
  ]
6411
 
6412
  [[package]]
6413
+ name = "roxmltree"
6414
+ version = "0.21.1"
6415
  source = "registry+https://github.com/rust-lang/crates.io-index"
6416
+ checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
6417
  dependencies = [
6418
+ "memchr",
 
 
6419
  ]
6420
 
6421
  [[package]]
 
6906
  "lazy_static",
6907
  ]
6908
 
 
 
 
 
 
 
 
 
 
6909
  [[package]]
6910
  name = "shlex"
6911
  version = "1.3.0"
 
7785
  "tokio",
7786
  ]
7787
 
7788
+ [[package]]
7789
+ name = "tokio-tungstenite"
7790
+ version = "0.28.0"
7791
+ source = "registry+https://github.com/rust-lang/crates.io-index"
7792
+ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
7793
+ dependencies = [
7794
+ "futures-util",
7795
+ "log",
7796
+ "tokio",
7797
+ "tungstenite",
7798
+ ]
7799
+
7800
  [[package]]
7801
  name = "tokio-util"
7802
  version = "0.7.18"
 
8074
  source = "registry+https://github.com/rust-lang/crates.io-index"
8075
  checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
8076
 
8077
+ [[package]]
8078
+ name = "tungstenite"
8079
+ version = "0.28.0"
8080
+ source = "registry+https://github.com/rust-lang/crates.io-index"
8081
+ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
8082
+ dependencies = [
8083
+ "bytes",
8084
+ "data-encoding",
8085
+ "http",
8086
+ "httparse",
8087
+ "log",
8088
+ "rand 0.9.2",
8089
+ "sha1",
8090
+ "thiserror 2.0.18",
8091
+ "utf-8",
8092
+ ]
8093
+
8094
  [[package]]
8095
  name = "typed-path"
8096
  version = "0.12.2"
Cargo.toml CHANGED
@@ -106,13 +106,13 @@ fontdue = "0.9"
106
  wgpu = "28.0"
107
  etagere = "0.2"
108
  bytemuck = { version = "1.21", features = ["derive"] }
109
- axum = { version = "0.8", features = ["json", "multipart"] }
110
- tower-http = { version = "0.6", features = ["fs", "cors"] }
111
- rust-embed = { version = "8.11", features = [
112
- "interpolate-folder-path",
113
- "debug-embed",
114
- "mime-guess",
115
- ] }
116
  criterion = { version = "0.8", features = ["html_reports"] }
117
  git-version = "0.3"
118
 
 
106
  wgpu = "28.0"
107
  etagere = "0.2"
108
  bytemuck = { version = "1.21", features = ["derive"] }
109
+ axum = { version = "0.8", features = ["ws"] }
110
+ tower-http = { version = "0.6", features = ["fs"] }
111
+ tower = { version = "0.5", features = ["timeout", "util"] }
112
+ rmp-serde = "1"
113
+ rmpv = { version = "1", features = ["with-serde"] }
114
+ enum_dispatch = "0.3"
115
+ async-trait = "0.1"
116
  criterion = { version = "0.8", features = ["html_reports"] }
117
  git-version = "0.3"
118
 
koharu/Cargo.toml CHANGED
@@ -50,7 +50,11 @@ sys-locale = { workspace = true }
50
  icu = { workspace = true }
51
  axum = { workspace = true }
52
  tower-http = { workspace = true }
53
- rust-embed = { workspace = true }
 
 
 
 
54
  git-version = { workspace = true }
55
 
56
  [target.'cfg(target_os = "windows")'.dependencies]
 
50
  icu = { workspace = true }
51
  axum = { workspace = true }
52
  tower-http = { workspace = true }
53
+ tower = { workspace = true }
54
+ rmp-serde = { workspace = true }
55
+ rmpv = { workspace = true }
56
+ enum_dispatch = { workspace = true }
57
+ async-trait = { workspace = true }
58
  git-version = { workspace = true }
59
 
60
  [target.'cfg(target_os = "windows")'.dependencies]
koharu/src/app.rs CHANGED
@@ -1,10 +1,4 @@
1
- use std::{
2
- path::PathBuf,
3
- sync::{
4
- Arc,
5
- atomic::{AtomicU16, Ordering},
6
- },
7
- };
8
 
9
  use anyhow::{Context, Result};
10
  use clap::Parser;
@@ -17,8 +11,9 @@ use tokio::{net::TcpListener, sync::RwLock};
17
  use tracing_subscriber::fmt::format::FmtSpan;
18
 
19
  use crate::{
20
- command, llm, ml,
21
  renderer::Renderer,
 
22
  server,
23
  state::{AppState, State},
24
  };
@@ -165,13 +160,11 @@ async fn update_app() -> Result<()> {
165
  async fn prefetch() -> Result<()> {
166
  ensure_dylibs(LIB_ROOT.to_path_buf()).await?;
167
  ml::prefetch().await?;
168
- // Skip for now as it's too big
169
- // llm::prefetch().await?;
170
 
171
  Ok(())
172
  }
173
 
174
- async fn build_resources(cpu: bool, _register_file_assoc: bool) -> Result<AppResources> {
175
  if cuda_is_available() {
176
  ensure_dylibs(LIB_ROOT.to_path_buf())
177
  .await
@@ -180,7 +173,7 @@ async fn build_resources(cpu: bool, _register_file_assoc: bool) -> Result<AppRes
180
 
181
  #[cfg(target_os = "windows")]
182
  {
183
- if _register_file_assoc && let Err(err) = crate::windows::register_khr() {
184
  tracing::warn!(?err, "Failed to register .khr file association");
185
  }
186
 
@@ -237,64 +230,49 @@ pub async fn run() -> Result<()> {
237
  }
238
  });
239
 
240
- if headless {
241
- let resources = build_resources(cpu, false).await?;
242
- let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0))).await?;
243
 
244
- let server_resources = resources.clone();
245
- tokio::spawn(async move {
246
- if let Err(err) = server::serve_with_listener(listener, server_resources).await {
247
- tracing::error!("HTTP server error: {err:#}");
 
 
 
 
 
 
248
  }
249
- });
 
250
 
 
 
 
251
  tokio::signal::ctrl_c().await?;
252
- return Ok(());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
- tauri::Builder::default()
256
- .invoke_handler(tauri::generate_handler![
257
- command::initialize,
258
- command::download_progress,
259
- ])
260
- .manage(AtomicU16::new(0))
261
- .setup(move |app| {
262
- let handle = app.handle().clone();
263
- tauri::async_runtime::spawn(async move {
264
- let resources = build_resources(cpu, true)
265
- .await
266
- .expect("failed to build app resources");
267
- let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0)))
268
- .await
269
- .expect("failed to bind HTTP server");
270
- let port = listener
271
- .local_addr()
272
- .expect("failed to get listener address")
273
- .port();
274
-
275
- handle.state::<AtomicU16>().store(port, Ordering::SeqCst);
276
-
277
- let server_resources = resources.clone();
278
- tokio::spawn(async move {
279
- server::serve_with_listener(listener, server_resources)
280
- .await
281
- .expect("failed to run HTTP server");
282
- });
283
-
284
- handle
285
- .get_webview_window("splashscreen")
286
- .expect("splashscreen window not found")
287
- .close()
288
- .ok();
289
- handle
290
- .get_webview_window("main")
291
- .expect("main window not found")
292
- .show()
293
- .ok();
294
- });
295
- Ok(())
296
- })
297
- .run(tauri::generate_context!())?;
298
-
299
  Ok(())
300
  }
 
1
+ use std::{path::PathBuf, sync::Arc};
 
 
 
 
 
 
2
 
3
  use anyhow::{Context, Result};
4
  use clap::Parser;
 
11
  use tracing_subscriber::fmt::format::FmtSpan;
12
 
13
  use crate::{
14
+ llm, ml,
15
  renderer::Renderer,
16
+ rpc::SharedResources,
17
  server,
18
  state::{AppState, State},
19
  };
 
160
  async fn prefetch() -> Result<()> {
161
  ensure_dylibs(LIB_ROOT.to_path_buf()).await?;
162
  ml::prefetch().await?;
 
 
163
 
164
  Ok(())
165
  }
166
 
167
+ async fn build_resources(cpu: bool) -> Result<AppResources> {
168
  if cuda_is_available() {
169
  ensure_dylibs(LIB_ROOT.to_path_buf())
170
  .await
 
173
 
174
  #[cfg(target_os = "windows")]
175
  {
176
+ if let Err(err) = crate::windows::register_khr() {
177
  tracing::warn!(?err, "Failed to register .khr file association");
178
  }
179
 
 
230
  }
231
  });
232
 
233
+ let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0))).await?;
234
+ let ws_port = listener.local_addr()?.port();
235
+ let shared: SharedResources = Arc::new(tokio::sync::OnceCell::new());
236
 
237
+ let app = tauri::Builder::default()
238
+ .append_invoke_initialization_script(format!("window.__KOHARU_WS_PORT__ = {};", ws_port))
239
+ .build(tauri::generate_context!())?;
240
+
241
+ let resolver = Arc::new(app.asset_resolver());
242
+ tokio::spawn({
243
+ let shared = shared.clone();
244
+ async move {
245
+ if let Err(err) = server::serve_with_listener(listener, shared, resolver).await {
246
+ tracing::error!("Server error: {err:#}");
247
  }
248
+ }
249
+ });
250
 
251
+ if headless {
252
+ let resources = build_resources(cpu).await?;
253
+ shared.set(resources).ok();
254
  tokio::signal::ctrl_c().await?;
255
+ } else {
256
+ let handle = app.handle().clone();
257
+ tokio::spawn(async move {
258
+ let resources = build_resources(cpu)
259
+ .await
260
+ .expect("failed to build app resources");
261
+ shared.set(resources).ok();
262
+
263
+ handle
264
+ .get_webview_window("splashscreen")
265
+ .expect("splashscreen window not found")
266
+ .close()
267
+ .ok();
268
+ handle
269
+ .get_webview_window("main")
270
+ .expect("main window not found")
271
+ .show()
272
+ .ok();
273
+ });
274
+ app.run(|_, _| {});
275
  }
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  Ok(())
278
  }
koharu/src/command.rs DELETED
@@ -1,39 +0,0 @@
1
- use std::sync::atomic::{AtomicU16, Ordering};
2
-
3
- use tauri::State;
4
- use tauri::ipc::Channel;
5
-
6
- use crate::result::Result;
7
-
8
- #[tauri::command]
9
- pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
10
- loop {
11
- let p = port.load(Ordering::SeqCst);
12
- if p != 0 {
13
- return Ok(p);
14
- }
15
-
16
- tokio::time::sleep(std::time::Duration::from_millis(50)).await;
17
- }
18
- }
19
-
20
- #[tauri::command]
21
- pub async fn download_progress(
22
- channel: Channel<koharu_core::download::DownloadProgress>,
23
- ) -> Result<()> {
24
- let mut rx = koharu_core::download::subscribe();
25
- tokio::spawn(async move {
26
- loop {
27
- match rx.recv().await {
28
- Ok(progress) => {
29
- if channel.send(progress).is_err() {
30
- break;
31
- }
32
- }
33
- Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
34
- Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
35
- }
36
- }
37
- });
38
- Ok(())
39
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu/src/lib.rs CHANGED
@@ -1,13 +1,13 @@
1
  pub mod app;
2
- pub mod command;
3
- pub mod endpoints;
4
  pub mod image;
5
  pub mod khr;
6
  pub mod llm;
7
  pub mod ml;
 
8
  pub mod pipeline;
9
  pub mod renderer;
10
  pub mod result;
 
11
  pub mod server;
12
  pub mod state;
13
  pub mod version;
 
1
  pub mod app;
 
 
2
  pub mod image;
3
  pub mod khr;
4
  pub mod llm;
5
  pub mod ml;
6
+ pub mod operations;
7
  pub mod pipeline;
8
  pub mod renderer;
9
  pub mod result;
10
+ pub mod rpc;
11
  pub mod server;
12
  pub mod state;
13
  pub mod version;
koharu/src/{endpoints.rs → operations.rs} RENAMED
@@ -1,20 +1,8 @@
1
  use std::{io::Cursor, path::PathBuf, str::FromStr};
2
 
3
- use std::convert::Infallible;
4
-
5
- use axum::{
6
- Json,
7
- body::Body,
8
- extract::{Multipart, State},
9
- http::{HeaderValue, StatusCode, header},
10
- response::{
11
- Response,
12
- sse::{Event, KeepAlive, Sse},
13
- },
14
- };
15
  use image::{GenericImageView, ImageFormat, RgbaImage};
16
  use koharu_ml::llm::ModelId;
17
- use koharu_renderer::renderer::{TextShaderEffect, WgpuDeviceInfo};
18
  use rayon::iter::{IntoParallelIterator, ParallelIterator};
19
  use serde::{Deserialize, Serialize};
20
  use strum::IntoEnumIterator;
@@ -26,27 +14,26 @@ use crate::{
26
  image::SerializableDynamicImage,
27
  khr::{deserialize_khr, has_khr_magic, serialize_khr},
28
  llm,
29
- result::Result,
30
  state::{Document, TextBlock},
31
  version,
32
  };
33
 
34
- pub async fn app_version(State(_state): State<AppResources>) -> Result<Json<String>> {
35
- Ok(Json(version::current().to_string()))
36
  }
37
 
38
  #[derive(Debug, Clone, Serialize, Deserialize)]
39
  #[serde(rename_all = "camelCase")]
40
  pub struct DeviceInfo {
41
  pub ml_device: String,
42
- pub wgpu: WgpuDeviceInfo,
43
  }
44
 
45
- pub async fn device(State(state): State<AppResources>) -> Result<Json<DeviceInfo>> {
46
- Ok(Json(DeviceInfo {
47
  ml_device: state.ml_device.to_string(),
48
  wgpu: state.renderer.wgpu_device_info(),
49
- }))
50
  }
51
 
52
  #[derive(Debug, Deserialize)]
@@ -55,14 +42,14 @@ pub struct OpenExternalPayload {
55
  pub url: String,
56
  }
57
 
58
- pub async fn open_external(Json(payload): Json<OpenExternalPayload>) -> Result<Json<()>> {
59
  open::that(&payload.url)?;
60
- Ok(Json(()))
61
  }
62
 
63
- pub async fn get_documents(State(state): State<AppResources>) -> Result<Json<usize>> {
64
  let guard = state.state.read().await;
65
- Ok(Json(guard.documents.len()))
66
  }
67
 
68
  #[derive(Debug, Deserialize)]
@@ -71,23 +58,28 @@ pub struct IndexPayload {
71
  pub index: usize,
72
  }
73
 
74
- pub async fn get_document(
75
- State(state): State<AppResources>,
76
- Json(payload): Json<IndexPayload>,
77
- ) -> Result<Json<Document>> {
78
  let guard = state.state.read().await;
79
- let doc = guard
80
  .documents
81
  .get(payload.index)
82
  .cloned()
83
- .ok_or_else(|| anyhow::anyhow!("Document not found at index {}", payload.index))?;
84
- Ok(Json(doc))
 
 
 
 
 
 
 
 
85
  }
86
 
87
  pub async fn get_thumbnail(
88
- State(state): State<AppResources>,
89
- Json(payload): Json<IndexPayload>,
90
- ) -> Result<Response> {
91
  let guard = state.state.read().await;
92
  let doc = guard
93
  .documents
@@ -100,43 +92,60 @@ pub async fn get_thumbnail(
100
  let mut buf = Cursor::new(Vec::new());
101
  thumbnail.write_to(&mut buf, ImageFormat::WebP)?;
102
 
103
- let mut response = Response::new(Body::from(buf.into_inner()));
104
- response
105
- .headers_mut()
106
- .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/webp"));
107
- Ok(response)
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  }
109
 
110
  pub async fn open_documents(
111
- State(state): State<AppResources>,
112
- mut multipart: Multipart,
113
- ) -> Result<Json<usize>> {
114
- let mut inputs: Vec<(PathBuf, Vec<u8>)> = Vec::new();
115
-
116
- while let Some(field) = multipart.next_field().await? {
117
- let name = field
118
- .file_name()
119
- .map(|s| s.to_string())
120
- .unwrap_or_else(|| "unknown".to_string());
121
- let bytes = field.bytes().await?.to_vec();
122
- inputs.push((PathBuf::from(name), bytes));
123
- }
124
 
125
  if inputs.is_empty() {
126
- Err(anyhow::anyhow!("No files uploaded"))?;
127
  }
128
 
129
  let docs = load_documents(inputs)?;
130
  let count = docs.len();
131
  let mut guard = state.state.write().await;
132
  guard.documents = docs;
133
- Ok(Json(count))
134
  }
135
 
136
- pub async fn save_documents(State(state): State<AppResources>) -> Result<Response> {
 
 
 
 
 
 
 
 
 
137
  let guard = state.state.read().await;
138
  if guard.documents.is_empty() {
139
- Err(anyhow::anyhow!("No documents to save"))?;
140
  }
141
 
142
  let filename = if guard.documents.len() == 1 {
@@ -153,17 +162,17 @@ pub async fn save_documents(State(state): State<AppResources>) -> Result<Respons
153
  let bytes = serialize_khr(&guard.documents)?;
154
  drop(guard);
155
 
156
- Ok(attachment_response(
157
- &filename,
158
- bytes,
159
- "application/octet-stream",
160
- )?)
161
  }
162
 
163
  pub async fn export_document(
164
- State(state): State<AppResources>,
165
- Json(payload): Json<IndexPayload>,
166
- ) -> Result<Response> {
167
  let guard = state.state.read().await;
168
  let document = guard
169
  .documents
@@ -184,16 +193,18 @@ pub async fn export_document(
184
 
185
  let bytes = encode_image(rendered, &ext)?;
186
  let filename = format!("{}_koharu.{}", document.name, ext);
 
187
  drop(guard);
188
 
189
- Ok(attachment_response(&filename, bytes, mime_from_ext(&ext))?)
 
 
 
 
190
  }
191
 
192
  #[instrument(level = "info", skip_all)]
193
- pub async fn detect(
194
- State(state): State<AppResources>,
195
- Json(payload): Json<IndexPayload>,
196
- ) -> Result<Json<()>> {
197
  let mut snapshot = {
198
  let guard = state.state.read().await;
199
  guard
@@ -211,14 +222,11 @@ pub async fn detect(
211
  .get_mut(payload.index)
212
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
213
  *document = snapshot;
214
- Ok(Json(()))
215
  }
216
 
217
  #[instrument(level = "info", skip_all)]
218
- pub async fn ocr(
219
- State(state): State<AppResources>,
220
- Json(payload): Json<IndexPayload>,
221
- ) -> Result<Json<()>> {
222
  let mut snapshot = {
223
  let guard = state.state.read().await;
224
  guard
@@ -236,14 +244,11 @@ pub async fn ocr(
236
  .get_mut(payload.index)
237
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
238
  *document = snapshot;
239
- Ok(Json(()))
240
  }
241
 
242
  #[instrument(level = "info", skip_all)]
243
- pub async fn inpaint(
244
- State(state): State<AppResources>,
245
- Json(payload): Json<IndexPayload>,
246
- ) -> Result<Json<()>> {
247
  let mut snapshot = {
248
  let guard = state.state.read().await;
249
  guard
@@ -261,21 +266,22 @@ pub async fn inpaint(
261
  .get_mut(payload.index)
262
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
263
  *document = snapshot;
264
- Ok(Json(()))
265
  }
266
 
267
  #[derive(Debug, Deserialize)]
268
  #[serde(rename_all = "camelCase")]
269
  pub struct UpdateInpaintMaskPayload {
270
  pub index: usize,
 
271
  pub mask: Vec<u8>,
272
  pub region: Option<InpaintRegion>,
273
  }
274
 
275
  pub async fn update_inpaint_mask(
276
- State(state): State<AppResources>,
277
- Json(payload): Json<UpdateInpaintMaskPayload>,
278
- ) -> Result<Json<()>> {
279
  let snapshot = {
280
  let guard = state.state.read().await;
281
  guard
@@ -298,13 +304,13 @@ pub async fn update_inpaint_mask(
298
  Some(region) => {
299
  let (patch_width, patch_height) = update_image.dimensions();
300
  if patch_width != region.width || patch_height != region.height {
301
- Err(anyhow::anyhow!(
302
  "Mask patch size mismatch: expected {}x{}, got {}x{}",
303
  region.width,
304
  region.height,
305
  patch_width,
306
  patch_height
307
- ))?;
308
  }
309
 
310
  let x0 = region.x.min(doc_width.saturating_sub(1));
@@ -313,7 +319,7 @@ pub async fn update_inpaint_mask(
313
  let y1 = region.y.saturating_add(region.height).min(doc_height);
314
 
315
  if x1 <= x0 || y1 <= y0 {
316
- return Ok(Json(()));
317
  }
318
 
319
  let patch_rgba = update_image.to_rgba8();
@@ -326,13 +332,13 @@ pub async fn update_inpaint_mask(
326
  None => {
327
  let (mask_width, mask_height) = update_image.dimensions();
328
  if mask_width != doc_width || mask_height != doc_height {
329
- Err(anyhow::anyhow!(
330
  "Mask size mismatch: expected {}x{}, got {}x{}",
331
  doc_width,
332
  doc_height,
333
  mask_width,
334
  mask_height
335
- ))?;
336
  }
337
  base_mask = update_image.to_rgba8();
338
  }
@@ -347,21 +353,22 @@ pub async fn update_inpaint_mask(
347
  .get_mut(payload.index)
348
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
349
  *document = updated;
350
- Ok(Json(()))
351
  }
352
 
353
  #[derive(Debug, Deserialize)]
354
  #[serde(rename_all = "camelCase")]
355
  pub struct UpdateBrushLayerPayload {
356
  pub index: usize,
 
357
  pub patch: Vec<u8>,
358
  pub region: InpaintRegion,
359
  }
360
 
361
  pub async fn update_brush_layer(
362
- State(state): State<AppResources>,
363
- Json(payload): Json<UpdateBrushLayerPayload>,
364
- ) -> Result<Json<()>> {
365
  let snapshot = {
366
  let guard = state.state.read().await;
367
  guard
@@ -373,20 +380,20 @@ pub async fn update_brush_layer(
373
 
374
  let (img_width, img_height) = (snapshot.width, snapshot.height);
375
  let Some((x0, y0, width, height)) = clamp_region(&payload.region, img_width, img_height) else {
376
- return Ok(Json(()));
377
  };
378
 
379
  let patch_image = image::load_from_memory(&payload.patch)?;
380
  let (patch_width, patch_height) = patch_image.dimensions();
381
 
382
  if patch_width != payload.region.width || patch_height != payload.region.height {
383
- Err(anyhow::anyhow!(
384
  "Brush patch size mismatch: expected {}x{}, got {}x{}",
385
  payload.region.width,
386
  payload.region.height,
387
  patch_width,
388
  patch_height
389
- ))?;
390
  }
391
 
392
  let brush_rgba = patch_image.to_rgba8();
@@ -411,7 +418,7 @@ pub async fn update_brush_layer(
411
  .get_mut(payload.index)
412
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
413
  *document = updated;
414
- Ok(Json(()))
415
  }
416
 
417
  #[derive(Debug, Deserialize)]
@@ -423,9 +430,9 @@ pub struct InpaintPartialPayload {
423
 
424
  #[instrument(level = "info", skip_all)]
425
  pub async fn inpaint_partial(
426
- State(state): State<AppResources>,
427
- Json(payload): Json<InpaintPartialPayload>,
428
- ) -> Result<Json<()>> {
429
  let snapshot = {
430
  let guard = state.state.read().await;
431
  guard
@@ -441,7 +448,7 @@ pub async fn inpaint_partial(
441
  .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
442
 
443
  if payload.region.width == 0 || payload.region.height == 0 {
444
- return Ok(Json(()));
445
  }
446
 
447
  let (img_width, img_height) = (snapshot.width, snapshot.height);
@@ -461,7 +468,7 @@ pub async fn inpaint_partial(
461
  let crop_height = y1.saturating_sub(y0);
462
 
463
  if crop_width == 0 || crop_height == 0 {
464
- return Ok(Json(()));
465
  }
466
 
467
  let patch_x1 = x0 + crop_width;
@@ -476,7 +483,7 @@ pub async fn inpaint_partial(
476
  });
477
 
478
  if !overlaps_text {
479
- return Ok(Json(()));
480
  }
481
 
482
  let image_crop =
@@ -517,7 +524,7 @@ pub async fn inpaint_partial(
517
  .get_mut(payload.index)
518
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
519
  *document = updated;
520
- Ok(Json(()))
521
  }
522
 
523
  #[derive(Debug, Deserialize)]
@@ -529,10 +536,7 @@ pub struct RenderPayload {
529
  }
530
 
531
  #[instrument(level = "info", skip_all)]
532
- pub async fn render(
533
- State(state): State<AppResources>,
534
- Json(payload): Json<RenderPayload>,
535
- ) -> Result<Json<()>> {
536
  let snapshot = {
537
  let guard = state.state.read().await;
538
  guard
@@ -555,7 +559,7 @@ pub async fn render(
555
  .get_mut(payload.index)
556
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
557
  *document = updated;
558
- Ok(Json(()))
559
  }
560
 
561
  #[derive(Debug, Deserialize)]
@@ -566,23 +570,23 @@ pub struct UpdateTextBlocksPayload {
566
  }
567
 
568
  pub async fn update_text_blocks(
569
- State(state): State<AppResources>,
570
- Json(payload): Json<UpdateTextBlocksPayload>,
571
- ) -> Result<Json<()>> {
572
  let mut guard = state.state.write().await;
573
  let document = guard
574
  .documents
575
  .get_mut(payload.index)
576
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
577
  document.text_blocks = payload.text_blocks;
578
- Ok(Json(()))
579
  }
580
 
581
- pub async fn list_font_families(State(state): State<AppResources>) -> Result<Json<Vec<String>>> {
582
- Ok(Json(state.renderer.available_fonts()?))
583
  }
584
 
585
- pub async fn llm_list(State(state): State<AppResources>) -> Result<Json<Vec<llm::ModelInfo>>> {
586
  let mut models: Vec<ModelId> = ModelId::iter().collect();
587
  let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 };
588
  let zh_locale_factor = match get_locale().unwrap_or_default() {
@@ -602,7 +606,7 @@ pub async fn llm_list(State(state): State<AppResources>) -> Result<Json<Vec<llm:
602
  ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
603
  });
604
 
605
- Ok(Json(models.into_iter().map(llm::ModelInfo::new).collect()))
606
  }
607
 
608
  #[derive(Debug, Deserialize)]
@@ -612,22 +616,19 @@ pub struct LlmLoadPayload {
612
  }
613
 
614
  #[instrument(level = "info", skip_all)]
615
- pub async fn llm_load(
616
- State(state): State<AppResources>,
617
- Json(payload): Json<LlmLoadPayload>,
618
- ) -> Result<Json<()>> {
619
  let id = ModelId::from_str(&payload.id)?;
620
  state.llm.load(id).await;
621
- Ok(Json(()))
622
  }
623
 
624
- pub async fn llm_offload(State(state): State<AppResources>) -> Result<Json<()>> {
625
  state.llm.offload().await;
626
- Ok(Json(()))
627
  }
628
 
629
- pub async fn llm_ready(State(state): State<AppResources>) -> Result<Json<bool>> {
630
- Ok(Json(state.llm.ready().await))
631
  }
632
 
633
  #[derive(Debug, Deserialize)]
@@ -639,10 +640,7 @@ pub struct LlmGeneratePayload {
639
  }
640
 
641
  #[instrument(level = "info", skip_all)]
642
- pub async fn llm_generate(
643
- State(state): State<AppResources>,
644
- Json(payload): Json<LlmGeneratePayload>,
645
- ) -> Result<Json<()>> {
646
  let snapshot = {
647
  let guard = state.state.read().await;
648
  guard
@@ -677,10 +675,49 @@ pub async fn llm_generate(
677
  .get_mut(payload.index)
678
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
679
  *document = updated;
680
- Ok(Json(()))
681
  }
682
 
683
- // Helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
 
685
  #[derive(Debug, Clone, Serialize, Deserialize)]
686
  #[serde(rename_all = "camelCase")]
@@ -715,21 +752,13 @@ fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result<V
715
  Ok(buf)
716
  }
717
 
718
- fn attachment_response(
719
- filename: &str,
720
- bytes: Vec<u8>,
721
- content_type: &str,
722
- ) -> anyhow::Result<Response> {
723
- let mut response = Response::new(Body::from(bytes));
724
- *response.status_mut() = StatusCode::OK;
725
- response
726
- .headers_mut()
727
- .insert(header::CONTENT_TYPE, HeaderValue::from_str(content_type)?);
728
- response.headers_mut().insert(
729
- header::CONTENT_DISPOSITION,
730
- HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))?,
731
- );
732
- Ok(response)
733
  }
734
 
735
  pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Document>> {
@@ -760,87 +789,7 @@ pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Doc
760
  Ok(documents)
761
  }
762
 
763
- fn mime_from_ext(ext: &str) -> &'static str {
764
- match ext {
765
- "png" => "image/png",
766
- "jpg" | "jpeg" => "image/jpeg",
767
- "webp" => "image/webp",
768
- _ => "application/octet-stream",
769
- }
770
- }
771
-
772
  fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDynamicImage {
773
  let blank = RgbaImage::from_pixel(width, height, color);
774
  image::DynamicImage::ImageRgba8(blank).into()
775
  }
776
-
777
- pub async fn download_progress()
778
- -> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
779
- let rx = koharu_core::download::subscribe();
780
- let stream = futures::stream::unfold(rx, |mut rx| async {
781
- loop {
782
- match rx.recv().await {
783
- Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
784
- Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
785
- Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
786
- }
787
- }
788
- });
789
- Sse::new(stream).keep_alive(KeepAlive::default())
790
- }
791
-
792
- // --- Auto-processing pipeline endpoints ---
793
-
794
- /// Start the processing pipeline. Returns immediately; progress is available
795
- /// via the `process_progress` SSE endpoint.
796
- pub async fn process(
797
- State(state): State<AppResources>,
798
- Json(payload): Json<crate::pipeline::ProcessRequest>,
799
- ) -> Result<Json<()>> {
800
- {
801
- let guard = state.pipeline.read().await;
802
- if guard.is_some() {
803
- Err(anyhow::anyhow!("A processing pipeline is already running"))?;
804
- }
805
- }
806
-
807
- let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
808
- {
809
- let mut guard = state.pipeline.write().await;
810
- *guard = Some(crate::pipeline::PipelineHandle {
811
- cancel: cancel.clone(),
812
- });
813
- }
814
-
815
- let resources = state.clone();
816
- tokio::spawn(async move {
817
- crate::pipeline::run_pipeline(resources, payload, cancel).await;
818
- });
819
-
820
- Ok(Json(()))
821
- }
822
-
823
- pub async fn process_progress()
824
- -> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
825
- let rx = crate::pipeline::subscribe();
826
- let stream = futures::stream::unfold(rx, |mut rx| async {
827
- loop {
828
- match rx.recv().await {
829
- Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
830
- Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
831
- Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
832
- }
833
- }
834
- });
835
- Sse::new(stream).keep_alive(KeepAlive::default())
836
- }
837
-
838
- pub async fn process_cancel(State(state): State<AppResources>) -> Result<Json<()>> {
839
- let guard = state.pipeline.read().await;
840
- if let Some(handle) = guard.as_ref() {
841
- handle
842
- .cancel
843
- .store(true, std::sync::atomic::Ordering::Relaxed);
844
- }
845
- Ok(Json(()))
846
- }
 
1
  use std::{io::Cursor, path::PathBuf, str::FromStr};
2
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  use image::{GenericImageView, ImageFormat, RgbaImage};
4
  use koharu_ml::llm::ModelId;
5
+ use koharu_renderer::renderer::TextShaderEffect;
6
  use rayon::iter::{IntoParallelIterator, ParallelIterator};
7
  use serde::{Deserialize, Serialize};
8
  use strum::IntoEnumIterator;
 
14
  image::SerializableDynamicImage,
15
  khr::{deserialize_khr, has_khr_magic, serialize_khr},
16
  llm,
 
17
  state::{Document, TextBlock},
18
  version,
19
  };
20
 
21
+ pub async fn app_version(_state: AppResources) -> anyhow::Result<String> {
22
+ Ok(version::current().to_string())
23
  }
24
 
25
  #[derive(Debug, Clone, Serialize, Deserialize)]
26
  #[serde(rename_all = "camelCase")]
27
  pub struct DeviceInfo {
28
  pub ml_device: String,
29
+ pub wgpu: koharu_renderer::renderer::WgpuDeviceInfo,
30
  }
31
 
32
+ pub async fn device(state: AppResources) -> anyhow::Result<DeviceInfo> {
33
+ Ok(DeviceInfo {
34
  ml_device: state.ml_device.to_string(),
35
  wgpu: state.renderer.wgpu_device_info(),
36
+ })
37
  }
38
 
39
  #[derive(Debug, Deserialize)]
 
42
  pub url: String,
43
  }
44
 
45
+ pub fn open_external(payload: OpenExternalPayload) -> anyhow::Result<()> {
46
  open::that(&payload.url)?;
47
+ Ok(())
48
  }
49
 
50
+ pub async fn get_documents(state: AppResources) -> anyhow::Result<usize> {
51
  let guard = state.state.read().await;
52
+ Ok(guard.documents.len())
53
  }
54
 
55
  #[derive(Debug, Deserialize)]
 
58
  pub index: usize,
59
  }
60
 
61
+ pub async fn get_document(state: AppResources, payload: IndexPayload) -> anyhow::Result<Document> {
 
 
 
62
  let guard = state.state.read().await;
63
+ guard
64
  .documents
65
  .get(payload.index)
66
  .cloned()
67
+ .ok_or_else(|| anyhow::anyhow!("Document not found at index {}", payload.index))
68
+ }
69
+
70
+ /// Returns WebP-encoded thumbnail bytes.
71
+ #[derive(Debug, Serialize)]
72
+ #[serde(rename_all = "camelCase")]
73
+ pub struct ThumbnailResult {
74
+ #[serde(with = "serde_bytes")]
75
+ pub data: Vec<u8>,
76
+ pub content_type: String,
77
  }
78
 
79
  pub async fn get_thumbnail(
80
+ state: AppResources,
81
+ payload: IndexPayload,
82
+ ) -> anyhow::Result<ThumbnailResult> {
83
  let guard = state.state.read().await;
84
  let doc = guard
85
  .documents
 
92
  let mut buf = Cursor::new(Vec::new());
93
  thumbnail.write_to(&mut buf, ImageFormat::WebP)?;
94
 
95
+ Ok(ThumbnailResult {
96
+ data: buf.into_inner(),
97
+ content_type: "image/webp".to_string(),
98
+ })
99
+ }
100
+
101
+ #[derive(Debug, Deserialize)]
102
+ #[serde(rename_all = "camelCase")]
103
+ pub struct FileEntry {
104
+ pub name: String,
105
+ #[serde(with = "serde_bytes")]
106
+ pub data: Vec<u8>,
107
+ }
108
+
109
+ #[derive(Debug, Deserialize)]
110
+ #[serde(rename_all = "camelCase")]
111
+ pub struct OpenDocumentsPayload {
112
+ pub files: Vec<FileEntry>,
113
  }
114
 
115
  pub async fn open_documents(
116
+ state: AppResources,
117
+ payload: OpenDocumentsPayload,
118
+ ) -> anyhow::Result<usize> {
119
+ let inputs: Vec<(PathBuf, Vec<u8>)> = payload
120
+ .files
121
+ .into_iter()
122
+ .map(|f| (PathBuf::from(f.name), f.data))
123
+ .collect();
 
 
 
 
 
124
 
125
  if inputs.is_empty() {
126
+ anyhow::bail!("No files uploaded");
127
  }
128
 
129
  let docs = load_documents(inputs)?;
130
  let count = docs.len();
131
  let mut guard = state.state.write().await;
132
  guard.documents = docs;
133
+ Ok(count)
134
  }
135
 
136
+ #[derive(Debug, Serialize)]
137
+ #[serde(rename_all = "camelCase")]
138
+ pub struct FileResult {
139
+ pub filename: String,
140
+ #[serde(with = "serde_bytes")]
141
+ pub data: Vec<u8>,
142
+ pub content_type: String,
143
+ }
144
+
145
+ pub async fn save_documents(state: AppResources) -> anyhow::Result<FileResult> {
146
  let guard = state.state.read().await;
147
  if guard.documents.is_empty() {
148
+ anyhow::bail!("No documents to save");
149
  }
150
 
151
  let filename = if guard.documents.len() == 1 {
 
162
  let bytes = serialize_khr(&guard.documents)?;
163
  drop(guard);
164
 
165
+ Ok(FileResult {
166
+ filename,
167
+ data: bytes,
168
+ content_type: "application/octet-stream".to_string(),
169
+ })
170
  }
171
 
172
  pub async fn export_document(
173
+ state: AppResources,
174
+ payload: IndexPayload,
175
+ ) -> anyhow::Result<FileResult> {
176
  let guard = state.state.read().await;
177
  let document = guard
178
  .documents
 
193
 
194
  let bytes = encode_image(rendered, &ext)?;
195
  let filename = format!("{}_koharu.{}", document.name, ext);
196
+ let content_type = mime_from_ext(&ext).to_string();
197
  drop(guard);
198
 
199
+ Ok(FileResult {
200
+ filename,
201
+ data: bytes,
202
+ content_type,
203
+ })
204
  }
205
 
206
  #[instrument(level = "info", skip_all)]
207
+ pub async fn detect(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
 
 
 
208
  let mut snapshot = {
209
  let guard = state.state.read().await;
210
  guard
 
222
  .get_mut(payload.index)
223
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
224
  *document = snapshot;
225
+ Ok(())
226
  }
227
 
228
  #[instrument(level = "info", skip_all)]
229
+ pub async fn ocr(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
 
 
 
230
  let mut snapshot = {
231
  let guard = state.state.read().await;
232
  guard
 
244
  .get_mut(payload.index)
245
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
246
  *document = snapshot;
247
+ Ok(())
248
  }
249
 
250
  #[instrument(level = "info", skip_all)]
251
+ pub async fn inpaint(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
 
 
 
252
  let mut snapshot = {
253
  let guard = state.state.read().await;
254
  guard
 
266
  .get_mut(payload.index)
267
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
268
  *document = snapshot;
269
+ Ok(())
270
  }
271
 
272
  #[derive(Debug, Deserialize)]
273
  #[serde(rename_all = "camelCase")]
274
  pub struct UpdateInpaintMaskPayload {
275
  pub index: usize,
276
+ #[serde(with = "serde_bytes")]
277
  pub mask: Vec<u8>,
278
  pub region: Option<InpaintRegion>,
279
  }
280
 
281
  pub async fn update_inpaint_mask(
282
+ state: AppResources,
283
+ payload: UpdateInpaintMaskPayload,
284
+ ) -> anyhow::Result<()> {
285
  let snapshot = {
286
  let guard = state.state.read().await;
287
  guard
 
304
  Some(region) => {
305
  let (patch_width, patch_height) = update_image.dimensions();
306
  if patch_width != region.width || patch_height != region.height {
307
+ anyhow::bail!(
308
  "Mask patch size mismatch: expected {}x{}, got {}x{}",
309
  region.width,
310
  region.height,
311
  patch_width,
312
  patch_height
313
+ );
314
  }
315
 
316
  let x0 = region.x.min(doc_width.saturating_sub(1));
 
319
  let y1 = region.y.saturating_add(region.height).min(doc_height);
320
 
321
  if x1 <= x0 || y1 <= y0 {
322
+ return Ok(());
323
  }
324
 
325
  let patch_rgba = update_image.to_rgba8();
 
332
  None => {
333
  let (mask_width, mask_height) = update_image.dimensions();
334
  if mask_width != doc_width || mask_height != doc_height {
335
+ anyhow::bail!(
336
  "Mask size mismatch: expected {}x{}, got {}x{}",
337
  doc_width,
338
  doc_height,
339
  mask_width,
340
  mask_height
341
+ );
342
  }
343
  base_mask = update_image.to_rgba8();
344
  }
 
353
  .get_mut(payload.index)
354
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
355
  *document = updated;
356
+ Ok(())
357
  }
358
 
359
  #[derive(Debug, Deserialize)]
360
  #[serde(rename_all = "camelCase")]
361
  pub struct UpdateBrushLayerPayload {
362
  pub index: usize,
363
+ #[serde(with = "serde_bytes")]
364
  pub patch: Vec<u8>,
365
  pub region: InpaintRegion,
366
  }
367
 
368
  pub async fn update_brush_layer(
369
+ state: AppResources,
370
+ payload: UpdateBrushLayerPayload,
371
+ ) -> anyhow::Result<()> {
372
  let snapshot = {
373
  let guard = state.state.read().await;
374
  guard
 
380
 
381
  let (img_width, img_height) = (snapshot.width, snapshot.height);
382
  let Some((x0, y0, width, height)) = clamp_region(&payload.region, img_width, img_height) else {
383
+ return Ok(());
384
  };
385
 
386
  let patch_image = image::load_from_memory(&payload.patch)?;
387
  let (patch_width, patch_height) = patch_image.dimensions();
388
 
389
  if patch_width != payload.region.width || patch_height != payload.region.height {
390
+ anyhow::bail!(
391
  "Brush patch size mismatch: expected {}x{}, got {}x{}",
392
  payload.region.width,
393
  payload.region.height,
394
  patch_width,
395
  patch_height
396
+ );
397
  }
398
 
399
  let brush_rgba = patch_image.to_rgba8();
 
418
  .get_mut(payload.index)
419
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
420
  *document = updated;
421
+ Ok(())
422
  }
423
 
424
  #[derive(Debug, Deserialize)]
 
430
 
431
  #[instrument(level = "info", skip_all)]
432
  pub async fn inpaint_partial(
433
+ state: AppResources,
434
+ payload: InpaintPartialPayload,
435
+ ) -> anyhow::Result<()> {
436
  let snapshot = {
437
  let guard = state.state.read().await;
438
  guard
 
448
  .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
449
 
450
  if payload.region.width == 0 || payload.region.height == 0 {
451
+ return Ok(());
452
  }
453
 
454
  let (img_width, img_height) = (snapshot.width, snapshot.height);
 
468
  let crop_height = y1.saturating_sub(y0);
469
 
470
  if crop_width == 0 || crop_height == 0 {
471
+ return Ok(());
472
  }
473
 
474
  let patch_x1 = x0 + crop_width;
 
483
  });
484
 
485
  if !overlaps_text {
486
+ return Ok(());
487
  }
488
 
489
  let image_crop =
 
524
  .get_mut(payload.index)
525
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
526
  *document = updated;
527
+ Ok(())
528
  }
529
 
530
  #[derive(Debug, Deserialize)]
 
536
  }
537
 
538
  #[instrument(level = "info", skip_all)]
539
+ pub async fn render(state: AppResources, payload: RenderPayload) -> anyhow::Result<()> {
 
 
 
540
  let snapshot = {
541
  let guard = state.state.read().await;
542
  guard
 
559
  .get_mut(payload.index)
560
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
561
  *document = updated;
562
+ Ok(())
563
  }
564
 
565
  #[derive(Debug, Deserialize)]
 
570
  }
571
 
572
  pub async fn update_text_blocks(
573
+ state: AppResources,
574
+ payload: UpdateTextBlocksPayload,
575
+ ) -> anyhow::Result<()> {
576
  let mut guard = state.state.write().await;
577
  let document = guard
578
  .documents
579
  .get_mut(payload.index)
580
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
581
  document.text_blocks = payload.text_blocks;
582
+ Ok(())
583
  }
584
 
585
+ pub async fn list_font_families(state: AppResources) -> anyhow::Result<Vec<String>> {
586
+ state.renderer.available_fonts()
587
  }
588
 
589
+ pub async fn llm_list(state: AppResources) -> anyhow::Result<Vec<llm::ModelInfo>> {
590
  let mut models: Vec<ModelId> = ModelId::iter().collect();
591
  let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 };
592
  let zh_locale_factor = match get_locale().unwrap_or_default() {
 
606
  ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
607
  });
608
 
609
+ Ok(models.into_iter().map(llm::ModelInfo::new).collect())
610
  }
611
 
612
  #[derive(Debug, Deserialize)]
 
616
  }
617
 
618
  #[instrument(level = "info", skip_all)]
619
+ pub async fn llm_load(state: AppResources, payload: LlmLoadPayload) -> anyhow::Result<()> {
 
 
 
620
  let id = ModelId::from_str(&payload.id)?;
621
  state.llm.load(id).await;
622
+ Ok(())
623
  }
624
 
625
+ pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> {
626
  state.llm.offload().await;
627
+ Ok(())
628
  }
629
 
630
+ pub async fn llm_ready(state: AppResources) -> anyhow::Result<bool> {
631
+ Ok(state.llm.ready().await)
632
  }
633
 
634
  #[derive(Debug, Deserialize)]
 
640
  }
641
 
642
  #[instrument(level = "info", skip_all)]
643
+ pub async fn llm_generate(state: AppResources, payload: LlmGeneratePayload) -> anyhow::Result<()> {
 
 
 
644
  let snapshot = {
645
  let guard = state.state.read().await;
646
  guard
 
675
  .get_mut(payload.index)
676
  .ok_or_else(|| anyhow::anyhow!("Document not found"))?;
677
  *document = updated;
678
+ Ok(())
679
  }
680
 
681
+ // --- Auto-processing pipeline endpoints ---
682
+
683
+ pub async fn process(
684
+ state: AppResources,
685
+ payload: crate::pipeline::ProcessRequest,
686
+ ) -> anyhow::Result<()> {
687
+ {
688
+ let guard = state.pipeline.read().await;
689
+ if guard.is_some() {
690
+ anyhow::bail!("A processing pipeline is already running");
691
+ }
692
+ }
693
+
694
+ let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
695
+ {
696
+ let mut guard = state.pipeline.write().await;
697
+ *guard = Some(crate::pipeline::PipelineHandle {
698
+ cancel: cancel.clone(),
699
+ });
700
+ }
701
+
702
+ let resources = state.clone();
703
+ tokio::spawn(async move {
704
+ crate::pipeline::run_pipeline(resources, payload, cancel).await;
705
+ });
706
+
707
+ Ok(())
708
+ }
709
+
710
+ pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> {
711
+ let guard = state.pipeline.read().await;
712
+ if let Some(handle) = guard.as_ref() {
713
+ handle
714
+ .cancel
715
+ .store(true, std::sync::atomic::Ordering::Relaxed);
716
+ }
717
+ Ok(())
718
+ }
719
+
720
+ // --- Helpers ---
721
 
722
  #[derive(Debug, Clone, Serialize, Deserialize)]
723
  #[serde(rename_all = "camelCase")]
 
752
  Ok(buf)
753
  }
754
 
755
+ fn mime_from_ext(ext: &str) -> &'static str {
756
+ match ext {
757
+ "png" => "image/png",
758
+ "jpg" | "jpeg" => "image/jpeg",
759
+ "webp" => "image/webp",
760
+ _ => "application/octet-stream",
761
+ }
 
 
 
 
 
 
 
 
762
  }
763
 
764
  pub fn load_documents(inputs: Vec<(PathBuf, Vec<u8>)>) -> anyhow::Result<Vec<Document>> {
 
789
  Ok(documents)
790
  }
791
 
 
 
 
 
 
 
 
 
 
792
  fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDynamicImage {
793
  let blank = RgbaImage::from_pixel(width, height, color);
794
  image::DynamicImage::ImageRgba8(blank).into()
795
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu/src/result.rs CHANGED
@@ -14,9 +14,6 @@ pub enum CommandError {
14
 
15
  #[error(transparent)]
16
  Image(#[from] image::ImageError),
17
-
18
- #[error(transparent)]
19
- Multipart(#[from] axum::extract::multipart::MultipartError),
20
  }
21
 
22
  impl serde::Serialize for CommandError {
 
14
 
15
  #[error(transparent)]
16
  Image(#[from] image::ImageError),
 
 
 
17
  }
18
 
19
  impl serde::Serialize for CommandError {
koharu/src/rpc.rs ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+ use std::time::Duration;
3
+
4
+ use anyhow::Result;
5
+ use async_trait::async_trait;
6
+ use axum::{
7
+ extract::{
8
+ State,
9
+ ws::{Message, WebSocket, WebSocketUpgrade},
10
+ },
11
+ response::IntoResponse,
12
+ };
13
+ use futures::{SinkExt, StreamExt};
14
+ use serde::{Deserialize, Serialize};
15
+ use tokio::sync::{broadcast, mpsc};
16
+ use tower::{Service, ServiceBuilder, ServiceExt, service_fn, timeout::TimeoutLayer};
17
+
18
+ use crate::app::AppResources;
19
+ use crate::operations;
20
+
21
+ // --- Shared Resources (lazy init) ---
22
+
23
+ pub type SharedResources = Arc<tokio::sync::OnceCell<AppResources>>;
24
+
25
+ // --- Wire Protocol ---
26
+
27
+ #[derive(Debug, Deserialize)]
28
+ struct RawIncoming {
29
+ id: u32,
30
+ method: String,
31
+ params: Option<rmpv::Value>,
32
+ }
33
+
34
+ #[derive(Debug, Serialize)]
35
+ #[serde(tag = "type")]
36
+ pub enum OutgoingMessage {
37
+ #[serde(rename = "res")]
38
+ Response {
39
+ id: u32,
40
+ #[serde(skip_serializing_if = "Option::is_none")]
41
+ result: Option<rmpv::Value>,
42
+ #[serde(skip_serializing_if = "Option::is_none")]
43
+ error: Option<String>,
44
+ },
45
+ #[serde(rename = "ntf")]
46
+ Notification { method: String, params: rmpv::Value },
47
+ }
48
+
49
+ fn ok_response(id: u32, result: rmpv::Value) -> OutgoingMessage {
50
+ OutgoingMessage::Response {
51
+ id,
52
+ result: Some(result),
53
+ error: None,
54
+ }
55
+ }
56
+
57
+ fn err_response(id: u32, msg: &str) -> OutgoingMessage {
58
+ OutgoingMessage::Response {
59
+ id,
60
+ result: None,
61
+ error: Some(msg.to_string()),
62
+ }
63
+ }
64
+
65
+ // --- Value conversion helpers ---
66
+
67
+ fn to_value<T: Serialize>(val: &T) -> Result<rmpv::Value> {
68
+ let bytes = rmp_serde::to_vec_named(val)?;
69
+ Ok(rmp_serde::from_slice(&bytes)?)
70
+ }
71
+
72
+ fn from_value<T: serde::de::DeserializeOwned>(val: rmpv::Value) -> Result<T> {
73
+ let bytes = rmp_serde::to_vec_named(&val)?;
74
+ Ok(rmp_serde::from_slice(&bytes)?)
75
+ }
76
+
77
+ // --- Handler Trait ---
78
+
79
+ #[async_trait]
80
+ #[enum_dispatch::enum_dispatch]
81
+ pub trait RpcCall {
82
+ async fn call(self, res: AppResources) -> Result<rmpv::Value>;
83
+ }
84
+
85
+ // --- Handler Structs ---
86
+
87
+ // No-param handlers
88
+
89
+ #[derive(Deserialize)]
90
+ pub struct AppVersionHandler;
91
+
92
+ #[async_trait]
93
+ impl RpcCall for AppVersionHandler {
94
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
95
+ let v = operations::app_version(state).await?;
96
+ to_value(&v)
97
+ }
98
+ }
99
+
100
+ #[derive(Deserialize)]
101
+ pub struct DeviceHandler;
102
+
103
+ #[async_trait]
104
+ impl RpcCall for DeviceHandler {
105
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
106
+ let v = operations::device(state).await?;
107
+ to_value(&v)
108
+ }
109
+ }
110
+
111
+ #[derive(Deserialize)]
112
+ pub struct GetDocumentsHandler;
113
+
114
+ #[async_trait]
115
+ impl RpcCall for GetDocumentsHandler {
116
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
117
+ let v = operations::get_documents(state).await?;
118
+ to_value(&v)
119
+ }
120
+ }
121
+
122
+ #[derive(Deserialize)]
123
+ pub struct ListFontFamiliesHandler;
124
+
125
+ #[async_trait]
126
+ impl RpcCall for ListFontFamiliesHandler {
127
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
128
+ let v = operations::list_font_families(state).await?;
129
+ to_value(&v)
130
+ }
131
+ }
132
+
133
+ #[derive(Deserialize)]
134
+ pub struct LlmListHandler;
135
+
136
+ #[async_trait]
137
+ impl RpcCall for LlmListHandler {
138
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
139
+ let v = operations::llm_list(state).await?;
140
+ to_value(&v)
141
+ }
142
+ }
143
+
144
+ #[derive(Deserialize)]
145
+ pub struct LlmOffloadHandler;
146
+
147
+ #[async_trait]
148
+ impl RpcCall for LlmOffloadHandler {
149
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
150
+ operations::llm_offload(state).await?;
151
+ Ok(rmpv::Value::Nil)
152
+ }
153
+ }
154
+
155
+ #[derive(Deserialize)]
156
+ pub struct LlmReadyHandler;
157
+
158
+ #[async_trait]
159
+ impl RpcCall for LlmReadyHandler {
160
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
161
+ let v = operations::llm_ready(state).await?;
162
+ to_value(&v)
163
+ }
164
+ }
165
+
166
+ #[derive(Deserialize)]
167
+ pub struct SaveDocumentsHandler;
168
+
169
+ #[async_trait]
170
+ impl RpcCall for SaveDocumentsHandler {
171
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
172
+ let v = operations::save_documents(state).await?;
173
+ to_value(&v)
174
+ }
175
+ }
176
+
177
+ #[derive(Deserialize)]
178
+ pub struct ProcessCancelHandler;
179
+
180
+ #[async_trait]
181
+ impl RpcCall for ProcessCancelHandler {
182
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
183
+ operations::process_cancel(state).await?;
184
+ Ok(rmpv::Value::Nil)
185
+ }
186
+ }
187
+
188
+ // Index-based handlers (wrapping IndexPayload)
189
+
190
+ #[derive(Deserialize)]
191
+ #[serde(transparent)]
192
+ pub struct GetDocumentHandler(operations::IndexPayload);
193
+
194
+ #[async_trait]
195
+ impl RpcCall for GetDocumentHandler {
196
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
197
+ let v = operations::get_document(state, self.0).await?;
198
+ to_value(&v)
199
+ }
200
+ }
201
+
202
+ #[derive(Deserialize)]
203
+ #[serde(transparent)]
204
+ pub struct GetThumbnailHandler(operations::IndexPayload);
205
+
206
+ #[async_trait]
207
+ impl RpcCall for GetThumbnailHandler {
208
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
209
+ let v = operations::get_thumbnail(state, self.0).await?;
210
+ to_value(&v)
211
+ }
212
+ }
213
+
214
+ #[derive(Deserialize)]
215
+ #[serde(transparent)]
216
+ pub struct ExportDocumentHandler(operations::IndexPayload);
217
+
218
+ #[async_trait]
219
+ impl RpcCall for ExportDocumentHandler {
220
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
221
+ let v = operations::export_document(state, self.0).await?;
222
+ to_value(&v)
223
+ }
224
+ }
225
+
226
+ #[derive(Deserialize)]
227
+ #[serde(transparent)]
228
+ pub struct DetectHandler(operations::IndexPayload);
229
+
230
+ #[async_trait]
231
+ impl RpcCall for DetectHandler {
232
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
233
+ operations::detect(state, self.0).await?;
234
+ Ok(rmpv::Value::Nil)
235
+ }
236
+ }
237
+
238
+ #[derive(Deserialize)]
239
+ #[serde(transparent)]
240
+ pub struct OcrHandler(operations::IndexPayload);
241
+
242
+ #[async_trait]
243
+ impl RpcCall for OcrHandler {
244
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
245
+ operations::ocr(state, self.0).await?;
246
+ Ok(rmpv::Value::Nil)
247
+ }
248
+ }
249
+
250
+ #[derive(Deserialize)]
251
+ #[serde(transparent)]
252
+ pub struct InpaintHandler(operations::IndexPayload);
253
+
254
+ #[async_trait]
255
+ impl RpcCall for InpaintHandler {
256
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
257
+ operations::inpaint(state, self.0).await?;
258
+ Ok(rmpv::Value::Nil)
259
+ }
260
+ }
261
+
262
+ // Complex payload handlers
263
+
264
+ #[derive(Deserialize)]
265
+ #[serde(transparent)]
266
+ pub struct OpenExternalHandler(operations::OpenExternalPayload);
267
+
268
+ #[async_trait]
269
+ impl RpcCall for OpenExternalHandler {
270
+ async fn call(self, _state: AppResources) -> Result<rmpv::Value> {
271
+ operations::open_external(self.0)?;
272
+ Ok(rmpv::Value::Nil)
273
+ }
274
+ }
275
+
276
+ #[derive(Deserialize)]
277
+ #[serde(transparent)]
278
+ pub struct OpenDocumentsHandler(operations::OpenDocumentsPayload);
279
+
280
+ #[async_trait]
281
+ impl RpcCall for OpenDocumentsHandler {
282
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
283
+ let v = operations::open_documents(state, self.0).await?;
284
+ to_value(&v)
285
+ }
286
+ }
287
+
288
+ #[derive(Deserialize)]
289
+ #[serde(transparent)]
290
+ pub struct UpdateInpaintMaskHandler(operations::UpdateInpaintMaskPayload);
291
+
292
+ #[async_trait]
293
+ impl RpcCall for UpdateInpaintMaskHandler {
294
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
295
+ operations::update_inpaint_mask(state, self.0).await?;
296
+ Ok(rmpv::Value::Nil)
297
+ }
298
+ }
299
+
300
+ #[derive(Deserialize)]
301
+ #[serde(transparent)]
302
+ pub struct UpdateBrushLayerHandler(operations::UpdateBrushLayerPayload);
303
+
304
+ #[async_trait]
305
+ impl RpcCall for UpdateBrushLayerHandler {
306
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
307
+ operations::update_brush_layer(state, self.0).await?;
308
+ Ok(rmpv::Value::Nil)
309
+ }
310
+ }
311
+
312
+ #[derive(Deserialize)]
313
+ #[serde(transparent)]
314
+ pub struct InpaintPartialHandler(operations::InpaintPartialPayload);
315
+
316
+ #[async_trait]
317
+ impl RpcCall for InpaintPartialHandler {
318
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
319
+ operations::inpaint_partial(state, self.0).await?;
320
+ Ok(rmpv::Value::Nil)
321
+ }
322
+ }
323
+
324
+ #[derive(Deserialize)]
325
+ #[serde(transparent)]
326
+ pub struct RenderHandler(operations::RenderPayload);
327
+
328
+ #[async_trait]
329
+ impl RpcCall for RenderHandler {
330
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
331
+ operations::render(state, self.0).await?;
332
+ Ok(rmpv::Value::Nil)
333
+ }
334
+ }
335
+
336
+ #[derive(Deserialize)]
337
+ #[serde(transparent)]
338
+ pub struct UpdateTextBlocksHandler(operations::UpdateTextBlocksPayload);
339
+
340
+ #[async_trait]
341
+ impl RpcCall for UpdateTextBlocksHandler {
342
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
343
+ operations::update_text_blocks(state, self.0).await?;
344
+ Ok(rmpv::Value::Nil)
345
+ }
346
+ }
347
+
348
+ #[derive(Deserialize)]
349
+ #[serde(transparent)]
350
+ pub struct LlmLoadHandler(operations::LlmLoadPayload);
351
+
352
+ #[async_trait]
353
+ impl RpcCall for LlmLoadHandler {
354
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
355
+ operations::llm_load(state, self.0).await?;
356
+ Ok(rmpv::Value::Nil)
357
+ }
358
+ }
359
+
360
+ #[derive(Deserialize)]
361
+ #[serde(transparent)]
362
+ pub struct LlmGenerateHandler(operations::LlmGeneratePayload);
363
+
364
+ #[async_trait]
365
+ impl RpcCall for LlmGenerateHandler {
366
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
367
+ operations::llm_generate(state, self.0).await?;
368
+ Ok(rmpv::Value::Nil)
369
+ }
370
+ }
371
+
372
+ #[derive(Deserialize)]
373
+ #[serde(transparent)]
374
+ pub struct ProcessHandler(crate::pipeline::ProcessRequest);
375
+
376
+ #[async_trait]
377
+ impl RpcCall for ProcessHandler {
378
+ async fn call(self, state: AppResources) -> Result<rmpv::Value> {
379
+ operations::process(state, self.0).await?;
380
+ Ok(rmpv::Value::Nil)
381
+ }
382
+ }
383
+
384
+ // --- Method Dispatch Enum ---
385
+
386
+ #[derive(Deserialize)]
387
+ #[serde(tag = "method", content = "params")]
388
+ #[enum_dispatch::enum_dispatch(RpcCall)]
389
+ pub enum Method {
390
+ #[serde(rename = "app_version")]
391
+ AppVersion(AppVersionHandler),
392
+ #[serde(rename = "device")]
393
+ Device(DeviceHandler),
394
+ #[serde(rename = "open_external")]
395
+ OpenExternal(OpenExternalHandler),
396
+ #[serde(rename = "get_documents")]
397
+ GetDocuments(GetDocumentsHandler),
398
+ #[serde(rename = "get_document")]
399
+ GetDocument(GetDocumentHandler),
400
+ #[serde(rename = "get_thumbnail")]
401
+ GetThumbnail(GetThumbnailHandler),
402
+ #[serde(rename = "open_documents")]
403
+ OpenDocuments(OpenDocumentsHandler),
404
+ #[serde(rename = "save_documents")]
405
+ SaveDocuments(SaveDocumentsHandler),
406
+ #[serde(rename = "export_document")]
407
+ ExportDocument(ExportDocumentHandler),
408
+ #[serde(rename = "detect")]
409
+ Detect(DetectHandler),
410
+ #[serde(rename = "ocr")]
411
+ Ocr(OcrHandler),
412
+ #[serde(rename = "inpaint")]
413
+ Inpaint(InpaintHandler),
414
+ #[serde(rename = "update_inpaint_mask")]
415
+ UpdateInpaintMask(UpdateInpaintMaskHandler),
416
+ #[serde(rename = "update_brush_layer")]
417
+ UpdateBrushLayer(UpdateBrushLayerHandler),
418
+ #[serde(rename = "inpaint_partial")]
419
+ InpaintPartial(InpaintPartialHandler),
420
+ #[serde(rename = "render")]
421
+ Render(RenderHandler),
422
+ #[serde(rename = "update_text_blocks")]
423
+ UpdateTextBlocks(UpdateTextBlocksHandler),
424
+ #[serde(rename = "list_font_families")]
425
+ ListFontFamilies(ListFontFamiliesHandler),
426
+ #[serde(rename = "llm_list")]
427
+ LlmList(LlmListHandler),
428
+ #[serde(rename = "llm_load")]
429
+ LlmLoad(LlmLoadHandler),
430
+ #[serde(rename = "llm_offload")]
431
+ LlmOffload(LlmOffloadHandler),
432
+ #[serde(rename = "llm_ready")]
433
+ LlmReady(LlmReadyHandler),
434
+ #[serde(rename = "llm_generate")]
435
+ LlmGenerate(LlmGenerateHandler),
436
+ #[serde(rename = "process")]
437
+ Process(ProcessHandler),
438
+ #[serde(rename = "process_cancel")]
439
+ ProcessCancel(ProcessCancelHandler),
440
+ }
441
+
442
+ /// Parse method name + params into a Method enum using serde adjacently-tagged deserialization.
443
+ fn parse_call(method: &str, params: rmpv::Value) -> Result<Method> {
444
+ let tagged = rmpv::Value::Map(vec![
445
+ (
446
+ rmpv::Value::String("method".into()),
447
+ rmpv::Value::String(method.into()),
448
+ ),
449
+ (rmpv::Value::String("params".into()), params),
450
+ ]);
451
+ from_value(tagged)
452
+ }
453
+
454
+ // --- WebSocket State ---
455
+
456
+ #[derive(Clone)]
457
+ pub struct WsState {
458
+ pub resources: SharedResources,
459
+ }
460
+
461
+ // --- WebSocket Handler ---
462
+
463
+ pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
464
+ ws.max_message_size(1024 * 1024 * 1024)
465
+ .on_upgrade(|socket| handle_socket(socket, state))
466
+ }
467
+
468
+ async fn handle_socket(socket: WebSocket, state: WsState) {
469
+ let (mut ws_sender, mut ws_receiver) = socket.split();
470
+ let (tx, mut send_rx) = mpsc::channel::<OutgoingMessage>(256);
471
+
472
+ // Notification forwarders
473
+ spawn_notification_forwarder(
474
+ "download_progress",
475
+ koharu_core::download::subscribe(),
476
+ tx.clone(),
477
+ );
478
+ spawn_notification_forwarder("process_progress", crate::pipeline::subscribe(), tx.clone());
479
+
480
+ // Sender task: drain mpsc → WebSocket binary frames
481
+ let send_task = tokio::spawn(async move {
482
+ while let Some(msg) = send_rx.recv().await {
483
+ let Ok(bytes) = rmp_serde::to_vec_named(&msg) else {
484
+ continue;
485
+ };
486
+ if ws_sender.send(Message::Binary(bytes.into())).await.is_err() {
487
+ break;
488
+ }
489
+ }
490
+ });
491
+
492
+ // Tower dispatch service (cloned per request)
493
+ let dispatch_svc = ServiceBuilder::new()
494
+ .layer(TimeoutLayer::new(Duration::from_secs(300)))
495
+ .service(service_fn(
496
+ |(method, res): (Method, AppResources)| async move { method.call(res).await },
497
+ ));
498
+
499
+ // Receive loop
500
+ while let Some(Ok(msg)) = ws_receiver.next().await {
501
+ let data = match msg {
502
+ Message::Binary(data) => data,
503
+ Message::Close(_) => break,
504
+ _ => continue,
505
+ };
506
+
507
+ let raw: RawIncoming = match rmp_serde::from_slice(&data) {
508
+ Ok(r) => r,
509
+ Err(e) => {
510
+ let _ = tx
511
+ .send(err_response(0, &format!("Decode error: {e}")))
512
+ .await;
513
+ continue;
514
+ }
515
+ };
516
+
517
+ let id = raw.id;
518
+ let tx = tx.clone();
519
+ let resources = state.resources.clone();
520
+ let mut svc = dispatch_svc.clone();
521
+
522
+ tokio::spawn(async move {
523
+ let response = match resources.get() {
524
+ Some(res) => {
525
+ let params = raw.params.unwrap_or(rmpv::Value::Nil);
526
+ match parse_call(&raw.method, params) {
527
+ Ok(method) => match svc.ready().await {
528
+ Ok(ready) => match ready.call((method, res.clone())).await {
529
+ Ok(result) => ok_response(id, result),
530
+ Err(e) => err_response(id, &format!("{e:#}")),
531
+ },
532
+ Err(e) => err_response(id, &format!("{e}")),
533
+ },
534
+ Err(e) => err_response(id, &format!("{e:#}")),
535
+ }
536
+ }
537
+ None => err_response(id, "Resources not initialized"),
538
+ };
539
+ let _ = tx.send(response).await;
540
+ });
541
+ }
542
+
543
+ send_task.abort();
544
+ }
545
+
546
+ fn spawn_notification_forwarder<T: Serialize + Clone + Send + 'static>(
547
+ method: &'static str,
548
+ mut rx: broadcast::Receiver<T>,
549
+ tx: mpsc::Sender<OutgoingMessage>,
550
+ ) {
551
+ tokio::spawn(async move {
552
+ loop {
553
+ match rx.recv().await {
554
+ Ok(payload) => {
555
+ let params = to_value(&payload).unwrap_or(rmpv::Value::Nil);
556
+ let msg = OutgoingMessage::Notification {
557
+ method: method.to_string(),
558
+ params,
559
+ };
560
+ if tx.send(msg).await.is_err() {
561
+ break;
562
+ }
563
+ }
564
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
565
+ Err(broadcast::error::RecvError::Closed) => break,
566
+ }
567
+ }
568
+ });
569
+ }
koharu/src/server.rs CHANGED
@@ -1,30 +1,31 @@
 
 
1
  use anyhow::Result;
2
  use axum::{
3
  Router,
4
  body::Body,
5
- extract::DefaultBodyLimit,
6
  http::{HeaderValue, StatusCode, Uri, header},
7
  response::{IntoResponse, Response},
8
- routing::{get, post},
9
  };
10
- use rust_embed::Embed;
11
  use tokio::net::TcpListener;
12
- use tower_http::cors::{CorsLayer, ExposeHeaders};
13
 
14
- use crate::{app::AppResources, endpoints::*};
15
 
16
- impl IntoResponse for crate::result::CommandError {
17
- fn into_response(self) -> Response {
18
- (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
19
- }
20
- }
21
 
22
- #[derive(Embed)]
23
- #[folder = "$CARGO_WORKSPACE_DIR/ui/out"]
24
- #[allow_missing = true]
25
- struct EmbeddedUi;
 
 
 
 
26
 
27
- async fn serve_embedded(uri: Uri) -> impl IntoResponse {
28
  let path = uri.path();
29
  let target = if path == "/" {
30
  "index.html"
@@ -32,64 +33,26 @@ async fn serve_embedded(uri: Uri) -> impl IntoResponse {
32
  path.trim_start_matches('/')
33
  };
34
 
35
- embedded_response(target)
36
- .or_else(|| embedded_response("index.html"))
37
  .unwrap_or_else(|| (StatusCode::NOT_FOUND, "Not Found").into_response())
38
  }
39
 
40
- fn embedded_response(path: &str) -> Option<Response> {
41
- let asset = EmbeddedUi::get(path)?;
42
- let mime = asset.metadata.mimetype();
43
- let mut response = Response::new(Body::from(asset.data.into_owned()));
44
- if let Ok(ct) = HeaderValue::from_str(mime) {
45
  response.headers_mut().insert(header::CONTENT_TYPE, ct);
46
  }
47
  Some(response)
48
  }
49
 
50
- fn build_router(state: AppResources) -> Router {
51
- Router::new()
52
- .route("/api/app_version", get(app_version).post(app_version))
53
- .route("/api/device", get(device).post(device))
54
- .route("/api/open_external", post(open_external))
55
- .route("/api/get_documents", get(get_documents).post(get_documents))
56
- .route("/api/get_document", get(get_document).post(get_document))
57
- .route("/api/get_thumbnail", get(get_thumbnail).post(get_thumbnail))
58
- .route("/api/open_documents", post(open_documents))
59
- .route("/api/save_documents", post(save_documents))
60
- .route("/api/export_document", post(export_document))
61
- .route("/api/detect", post(detect))
62
- .route("/api/ocr", post(ocr))
63
- .route("/api/inpaint", post(inpaint))
64
- .route("/api/update_inpaint_mask", post(update_inpaint_mask))
65
- .route("/api/update_brush_layer", post(update_brush_layer))
66
- .route("/api/inpaint_partial", post(inpaint_partial))
67
- .route("/api/render", post(render))
68
- .route("/api/update_text_blocks", post(update_text_blocks))
69
- .route(
70
- "/api/list_font_families",
71
- get(list_font_families).post(list_font_families),
72
- )
73
- .route("/api/llm_list", get(llm_list).post(llm_list))
74
- .route("/api/llm_load", post(llm_load))
75
- .route("/api/llm_offload", post(llm_offload))
76
- .route("/api/llm_ready", get(llm_ready).post(llm_ready))
77
- .route("/api/llm_generate", post(llm_generate))
78
- .route("/api/download_progress", get(download_progress))
79
- .route("/api/process", post(process))
80
- .route("/api/process_cancel", post(process_cancel))
81
- .route("/api/process_progress", get(process_progress))
82
- .with_state(state)
83
- .layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
84
- .layer(
85
- CorsLayer::very_permissive()
86
- .expose_headers(ExposeHeaders::list([header::CONTENT_DISPOSITION])),
87
- )
88
- .fallback(serve_embedded)
89
- }
90
-
91
- pub async fn serve_with_listener(listener: TcpListener, resources: AppResources) -> Result<()> {
92
- let router = build_router(resources);
93
  tracing::info!("HTTP server listening on http://{}", listener.local_addr()?);
94
  axum::serve(listener, router.into_make_service()).await?;
95
  Ok(())
 
1
+ use std::sync::Arc;
2
+
3
  use anyhow::Result;
4
  use axum::{
5
  Router,
6
  body::Body,
 
7
  http::{HeaderValue, StatusCode, Uri, header},
8
  response::{IntoResponse, Response},
9
+ routing::get,
10
  };
11
+ use tauri::AssetResolver;
12
  use tokio::net::TcpListener;
 
13
 
14
+ use crate::rpc::{self, SharedResources, WsState};
15
 
16
+ fn build_router(shared: SharedResources, resolver: Arc<AssetResolver<tauri::Wry>>) -> Router {
17
+ let ws_state = WsState { resources: shared };
 
 
 
18
 
19
+ Router::new()
20
+ .route("/ws", get(rpc::ws_handler))
21
+ .with_state(ws_state)
22
+ .fallback(move |uri: Uri| {
23
+ let resolver = resolver.clone();
24
+ async move { serve_asset(&resolver, uri) }
25
+ })
26
+ }
27
 
28
+ fn serve_asset(resolver: &AssetResolver<tauri::Wry>, uri: Uri) -> Response {
29
  let path = uri.path();
30
  let target = if path == "/" {
31
  "index.html"
 
33
  path.trim_start_matches('/')
34
  };
35
 
36
+ resolve_asset(resolver, target)
37
+ .or_else(|| resolve_asset(resolver, "index.html"))
38
  .unwrap_or_else(|| (StatusCode::NOT_FOUND, "Not Found").into_response())
39
  }
40
 
41
+ fn resolve_asset(resolver: &AssetResolver<tauri::Wry>, path: &str) -> Option<Response> {
42
+ let asset = resolver.get(path.to_string())?;
43
+ let mut response = Response::new(Body::from(asset.bytes));
44
+ if let Ok(ct) = HeaderValue::from_str(&asset.mime_type) {
 
45
  response.headers_mut().insert(header::CONTENT_TYPE, ct);
46
  }
47
  Some(response)
48
  }
49
 
50
+ pub async fn serve_with_listener(
51
+ listener: TcpListener,
52
+ shared: SharedResources,
53
+ resolver: Arc<AssetResolver<tauri::Wry>>,
54
+ ) -> Result<()> {
55
+ let router = build_router(shared, resolver);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  tracing::info!("HTTP server listening on http://{}", listener.local_addr()?);
57
  axum::serve(listener, router.into_make_service()).await?;
58
  Ok(())
ui/app/(app)/about/page.tsx CHANGED
@@ -30,7 +30,7 @@ export default function AboutPage() {
30
  const checkVersion = async () => {
31
  try {
32
  if (isTauri()) {
33
- const version = await invoke<string>('app_version')
34
  setAppVersion(version)
35
 
36
  const res = await fetch(
 
30
  const checkVersion = async () => {
31
  try {
32
  if (isTauri()) {
33
+ const version = await invoke('app_version')
34
  setAppVersion(version)
35
 
36
  const res = await fetch(
ui/app/(app)/settings/page.tsx CHANGED
@@ -20,19 +20,7 @@ import {
20
  SelectValue,
21
  } from '@/components/ui/select'
22
  import { invoke, isTauri } from '@/lib/backend'
23
-
24
- interface WgpuDeviceInfo {
25
- name: string
26
- backend: string
27
- deviceType: string
28
- driver: string
29
- driverInfo: string
30
- }
31
-
32
- interface DeviceInfo {
33
- mlDevice: string
34
- wgpu: WgpuDeviceInfo
35
- }
36
 
37
  const THEME_OPTIONS = [
38
  { value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
@@ -54,7 +42,7 @@ export default function SettingsPage() {
54
 
55
  const loadDeviceInfo = async () => {
56
  try {
57
- const info = await invoke<DeviceInfo>('device')
58
  setDeviceInfo(info)
59
  } catch (error) {
60
  console.error('Failed to load device info', error)
 
20
  SelectValue,
21
  } from '@/components/ui/select'
22
  import { invoke, isTauri } from '@/lib/backend'
23
+ import type { DeviceInfo } from '@/lib/rpc-types'
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  const THEME_OPTIONS = [
26
  { value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' },
 
42
 
43
  const loadDeviceInfo = async () => {
44
  try {
45
+ const info = await invoke('device')
46
  setDeviceInfo(info)
47
  } catch (error) {
48
  console.error('Failed to load device info', error)
ui/app/providers.tsx CHANGED
@@ -16,7 +16,7 @@ export function Providers({ children }: { children: ReactNode }) {
16
  let unlisten: (() => void) | undefined
17
  ;(async () => {
18
  try {
19
- const count = await invoke<number>('get_documents')
20
  if (count > 0) {
21
  setTotalPages(count)
22
  }
 
16
  let unlisten: (() => void) | undefined
17
  ;(async () => {
18
  try {
19
+ const count = await invoke('get_documents')
20
  if (count > 0) {
21
  setTotalPages(count)
22
  }
ui/app/splashscreen/page.tsx CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { useEffect, useRef, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
- import { isTauri, type DownloadProgress } from '@/lib/backend'
6
 
7
  type AggregateProgress = {
8
  filename: string
@@ -12,71 +12,65 @@ type AggregateProgress = {
12
  export default function SplashScreen() {
13
  const { t } = useTranslation()
14
  const [progress, setProgress] = useState<AggregateProgress | null>(null)
15
- const channelRef = useRef<unknown>(null)
16
  const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
17
  new Map(),
18
  )
19
 
20
  useEffect(() => {
21
- if (!isTauri()) return
22
- ;(async () => {
23
- const { Channel, invoke } = await import('@tauri-apps/api/core')
24
- const channel = new Channel<DownloadProgress>()
25
- channelRef.current = channel
26
- channel.onmessage = (msg) => {
27
- const files = filesRef.current
28
 
29
- if (msg.status === 'Started') {
30
- files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
31
- } else if (msg.status === 'Downloading') {
32
- const entry = files.get(msg.filename)
33
- if (entry) {
34
- entry.downloaded = msg.downloaded
35
- if (msg.total) entry.total = msg.total
36
- } else {
37
- files.set(msg.filename, {
38
- downloaded: msg.downloaded,
39
- total: msg.total ?? 0,
40
- })
41
- }
42
  } else {
43
- // Completed or Failed — lock this file at 100%
44
- const entry = files.get(msg.filename)
45
- if (entry) {
46
- entry.downloaded = entry.total
47
- }
48
  }
49
-
50
- // Compute aggregate
51
- let totalBytes = 0
52
- let downloadedBytes = 0
53
- for (const entry of files.values()) {
54
- totalBytes += entry.total
55
- downloadedBytes += entry.downloaded
56
  }
 
57
 
58
- // Find current active file (last non-completed)
59
- const activeFilename =
60
- msg.status === 'Started' || msg.status === 'Downloading'
61
- ? msg.filename
62
- : null
 
 
63
 
64
- const percent =
65
- totalBytes > 0
66
- ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
67
- : undefined
 
68
 
69
- if (activeFilename) {
70
- setProgress({ filename: activeFilename, percent })
71
- } else {
72
- // All done — keep showing 100% briefly
73
- setProgress((prev) =>
74
- prev ? { ...prev, percent: percent ?? 100 } : null,
75
- )
76
- }
 
 
 
 
77
  }
78
- await invoke('download_progress', { channel })
79
- })()
 
80
  }, [])
81
 
82
  return (
 
2
 
3
  import { useEffect, useRef, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { subscribeDownloadProgress, type DownloadProgress } from '@/lib/backend'
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
  const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
16
  new Map(),
17
  )
18
 
19
  useEffect(() => {
20
+ const unsub = subscribeDownloadProgress((msg: DownloadProgress) => {
21
+ const files = filesRef.current
 
 
 
 
 
22
 
23
+ if (msg.status === 'Started') {
24
+ files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
25
+ } else if (msg.status === 'Downloading') {
26
+ const entry = files.get(msg.filename)
27
+ if (entry) {
28
+ entry.downloaded = msg.downloaded
29
+ if (msg.total) entry.total = msg.total
 
 
 
 
 
 
30
  } else {
31
+ files.set(msg.filename, {
32
+ downloaded: msg.downloaded,
33
+ total: msg.total ?? 0,
34
+ })
 
35
  }
36
+ } else {
37
+ // Completed or Failed — lock this file at 100%
38
+ const entry = files.get(msg.filename)
39
+ if (entry) {
40
+ entry.downloaded = entry.total
 
 
41
  }
42
+ }
43
 
44
+ // Compute aggregate
45
+ let totalBytes = 0
46
+ let downloadedBytes = 0
47
+ for (const entry of files.values()) {
48
+ totalBytes += entry.total
49
+ downloadedBytes += entry.downloaded
50
+ }
51
 
52
+ // Find current active file (last non-completed)
53
+ const activeFilename =
54
+ msg.status === 'Started' || msg.status === 'Downloading'
55
+ ? msg.filename
56
+ : null
57
 
58
+ const percent =
59
+ totalBytes > 0
60
+ ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
61
+ : undefined
62
+
63
+ if (activeFilename) {
64
+ setProgress({ filename: activeFilename, percent })
65
+ } else {
66
+ // All done — keep showing 100% briefly
67
+ setProgress((prev) =>
68
+ prev ? { ...prev, percent: percent ?? 100 } : null,
69
+ )
70
  }
71
+ })
72
+
73
+ return () => unsub()
74
  }, [])
75
 
76
  return (
ui/components/Image.tsx CHANGED
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
5
  import { convertToBlob } from '@/lib/util'
6
 
7
  type ImageProps = {
8
- data?: number[]
9
  visible?: boolean
10
  opacity?: number
11
  transition?: boolean
 
5
  import { convertToBlob } from '@/lib/util'
6
 
7
  type ImageProps = {
8
+ data?: Uint8Array
9
  visible?: boolean
10
  opacity?: number
11
  transition?: boolean
ui/hooks/useMaskDrawing.ts CHANGED
@@ -201,7 +201,7 @@ export function useMaskDrawing({
201
  ctx.restore()
202
  }
203
 
204
- const exportMaskBytes = async (): Promise<number[] | null> => {
205
  const canvas = canvasRef.current
206
  if (!canvas) return null
207
  const blob = await new Promise<Blob | null>((resolve) => {
@@ -213,7 +213,7 @@ export function useMaskDrawing({
213
 
214
  const exportMaskPatch = async (
215
  region: InpaintRegion,
216
- ): Promise<number[] | null> => {
217
  const canvas = canvasRef.current
218
  if (!canvas || region.width <= 0 || region.height <= 0) return null
219
 
 
201
  ctx.restore()
202
  }
203
 
204
+ const exportMaskBytes = async (): Promise<Uint8Array | null> => {
205
  const canvas = canvasRef.current
206
  if (!canvas) return null
207
  const blob = await new Promise<Blob | null>((resolve) => {
 
213
 
214
  const exportMaskPatch = async (
215
  region: InpaintRegion,
216
+ ): Promise<Uint8Array | null> => {
217
  const canvas = canvasRef.current
218
  if (!canvas || region.width <= 0 || region.height <= 0) return null
219
 
ui/hooks/useRenderBrushDrawing.ts CHANGED
@@ -152,7 +152,7 @@ export function useRenderBrushDrawing({
152
 
153
  const exportPatch = async (
154
  region: InpaintRegion,
155
- ): Promise<number[] | null> => {
156
  const canvas = targetCanvasRef?.current ?? canvasRef.current
157
  if (!canvas || region.width <= 0 || region.height <= 0) return null
158
 
 
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
 
ui/lib/backend.ts CHANGED
@@ -1,32 +1,50 @@
1
  'use client'
2
 
 
3
  import { pickFiles, saveFile } from './fs'
 
 
4
 
5
- let apiBase = '/api'
6
- let _initPromise: Promise<void> | null = null
7
 
8
- const isTauriEnv = (): boolean =>
9
- typeof window !== 'undefined' && !!(window as any).__TAURI_INTERNALS__
10
 
11
- async function ensureInitialized(): Promise<void> {
12
- if (!isTauriEnv()) {
13
- // In browser/headless mode, use relative URLs
14
- return
15
- }
16
 
17
- if (_initPromise) {
18
- return _initPromise
 
 
 
 
 
 
 
 
 
 
19
  }
20
 
21
- _initPromise = (async () => {
22
- const { invoke } = await import('@tauri-apps/api/core')
23
- const port = await invoke<number>('initialize')
24
- apiBase = `http://127.0.0.1:${port}/api`
25
- })()
 
 
 
 
 
 
26
 
27
- return _initPromise
 
 
28
  }
29
 
 
 
30
  export enum ProgressBarStatus {
31
  None = 'none',
32
  Normal = 'normal',
@@ -54,12 +72,13 @@ export function getCurrentWindow(): ProgressTarget {
54
 
55
  return {
56
  async setProgressBar() {
57
- // no-op in headless mode
58
  return
59
  },
60
  }
61
  }
62
 
 
 
63
  export async function listen<T>(
64
  event: string,
65
  handler: (event: { payload: T }) => void,
@@ -78,227 +97,123 @@ export async function listen<T>(
78
  return async () => {}
79
  }
80
 
81
- export async function invoke<T>(
82
- cmd: string,
83
- args?: Record<string, any>,
84
- ): Promise<T> {
85
- await ensureInitialized()
86
-
87
- // Browser-only implementations (no Tauri available)
88
- if (!isTauriEnv()) {
89
- switch (cmd) {
90
- case 'open_external': {
91
- const url = typeof args?.url === 'string' ? args.url : undefined
92
- if (url) {
93
- window.open(url, '_blank', 'noopener,noreferrer')
94
- }
95
- return undefined as T
96
- }
97
- }
98
- }
99
 
100
- // File operations with special handling
101
- switch (cmd) {
102
- case 'open_documents':
103
- return (await openDocumentsHttp()) as T
104
- case 'save_documents':
105
- return (await saveBinary(`${apiBase}/save_documents`)) as T
106
- case 'export_document':
107
- return (await saveBinary(`${apiBase}/export_document`, args)) as T
108
- default:
109
- return invokeHttp<T>(cmd, args)
110
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
112
 
113
- async function invokeHttp<T>(
114
- cmd: string,
115
- args?: Record<string, any>,
116
- ): Promise<T> {
117
- const body = args ?? {}
118
- const res = await fetch(`${apiBase}/${cmd}`, {
119
- method: 'POST',
120
- headers: {
121
- 'Content-Type': 'application/json',
122
- },
123
- body: JSON.stringify(body),
124
- })
 
 
 
 
125
 
126
- if (!res.ok) {
127
- throw new Error(await readError(res))
 
128
  }
129
 
130
- const contentType = res.headers.get('content-type') ?? ''
131
- if (contentType.includes('application/json')) {
132
- return (await res.json()) as T
 
 
 
133
  }
134
 
135
- const buffer = await res.arrayBuffer()
136
- return buffer as unknown as T
137
  }
138
 
139
- async function openDocumentsHttp<T>(): Promise<T> {
140
  const files = await pickFiles(
141
  [
142
  {
143
  description: 'Documents',
144
  accept: {
145
- // 'application/octet-stream': ['.khr'],
146
  'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
147
  },
148
  },
149
  ],
150
  true,
151
  )
152
- if (!files.length) {
153
- return 0 as unknown as T
154
- }
155
-
156
- const formData = new FormData()
157
- for (const file of files) {
158
- formData.append('files', file, file.name)
159
- }
160
-
161
- const res = await fetch(`${apiBase}/open_documents`, {
162
- method: 'POST',
163
- body: formData,
164
- })
165
- if (!res.ok) {
166
- throw new Error(await readError(res))
167
- }
168
 
169
- return (await res.json()) as T
170
- }
171
-
172
- async function saveBinary(
173
- endpoint: string,
174
- args?: Record<string, any>,
175
- ): Promise<void> {
176
- const hasBody = args && Object.keys(args).length > 0
177
- const res = await fetch(endpoint, {
178
- method: 'POST',
179
- headers: hasBody ? { 'Content-Type': 'application/json' } : undefined,
180
- body: hasBody ? JSON.stringify(args) : undefined,
181
- })
182
-
183
- if (!res.ok) {
184
- throw new Error(await readError(res))
185
- }
186
-
187
- const blob = await res.blob()
188
- const suggestedName =
189
- parseFilename(res.headers.get('content-disposition')) ?? 'download.bin'
190
- await saveFile(blob, suggestedName)
191
- }
192
 
193
- function parseFilename(disposition?: string | null): string | undefined {
194
- if (!disposition) return undefined
195
- const match = /filename="?([^\";]+)"?/i.exec(disposition)
196
- return match?.[1]
197
  }
198
 
199
- async function readError(res: Response): Promise<string> {
200
- const contentType = res.headers.get('content-type') ?? ''
201
- if (contentType.includes('application/json')) {
202
- try {
203
- const body = (await res.json()) as { error?: string }
204
- if (body?.error) return body.error
205
- } catch (_) {}
206
- }
207
- try {
208
- return await res.text()
209
- } catch (_) {
210
- return res.statusText || 'Request failed'
211
- }
212
- }
213
 
214
  export async function fetchThumbnail(index: number): Promise<Blob> {
215
- await ensureInitialized()
216
- const res = await fetch(`${apiBase}/get_thumbnail`, {
217
- method: 'POST',
218
- headers: { 'Content-Type': 'application/json' },
219
- body: JSON.stringify({ index }),
 
220
  })
221
- if (!res.ok) {
222
- throw new Error(await readError(res))
223
- }
224
- return res.blob()
225
  }
226
 
227
- export type DownloadProgress = {
228
- filename: string
229
- downloaded: number
230
- total?: number
231
- status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
232
- }
233
 
234
- export type ProcessProgress = {
235
- status: 'running' | 'completed' | 'cancelled' | { failed: string }
236
- step: string | null
237
- currentDocument: number
238
- totalDocuments: number
239
- currentStepIndex: number
240
- totalSteps: number
241
- overallPercent: number
242
- }
243
 
244
- function subscribeSSE<T>(
245
- endpoint: string,
246
- callback: (data: T) => void,
247
  ): () => void {
248
- let es: EventSource | null = null
249
-
250
- ;(async () => {
251
- await ensureInitialized()
252
- es = new EventSource(`${apiBase}/${endpoint}`)
253
- es.onmessage = (event) => {
254
- try {
255
- callback(JSON.parse(event.data) as T)
256
- } catch (_) {}
257
- }
258
- })()
259
-
260
- return () => {
261
- es?.close()
262
- }
263
- }
264
-
265
- export const subscribeDownloadProgress = (cb: (p: DownloadProgress) => void) =>
266
- subscribeSSE<DownloadProgress>('download_progress', cb)
267
-
268
- export const subscribeProcessProgress = (cb: (p: ProcessProgress) => void) =>
269
- subscribeSSE<ProcessProgress>('process_progress', cb)
270
-
271
- export const isTauri = isTauriEnv
272
-
273
- export const isMacOS = (): boolean => {
274
- if (typeof window === 'undefined') return false
275
- return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
276
  }
277
 
278
- export const windowControls = {
279
- async minimize() {
280
- if (isTauriEnv()) {
281
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
282
- return getCurrentWindow().minimize()
283
- }
284
- },
285
- async toggleMaximize() {
286
- if (isTauriEnv()) {
287
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
288
- return getCurrentWindow().toggleMaximize()
289
- }
290
- },
291
- async close() {
292
- if (isTauriEnv()) {
293
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
294
- return getCurrentWindow().close()
295
- }
296
- },
297
- async isMaximized(): Promise<boolean> {
298
- if (isTauriEnv()) {
299
- const { getCurrentWindow } = await import('@tauri-apps/api/window')
300
- return getCurrentWindow().isMaximized()
301
- }
302
- return false
303
- },
304
  }
 
1
  'use client'
2
 
3
+ import { WsRpcClient } from './ws'
4
  import { pickFiles, saveFile } from './fs'
5
+ import { toArrayBuffer } from './util'
6
+ import type { RpcMethodMap, RpcNotificationMap, FileResult } from './rpc-types'
7
 
8
+ // --- Singleton client ---
 
9
 
10
+ let client: WsRpcClient | null = null
 
11
 
12
+ function getClient(): WsRpcClient {
13
+ if (client) return client
 
 
 
14
 
15
+ let url: string
16
+ if (typeof window !== 'undefined' && (window as any).__KOHARU_WS_PORT__) {
17
+ const port = (window as any).__KOHARU_WS_PORT__ as number
18
+ url = `ws://127.0.0.1:${port}/ws`
19
+ } else {
20
+ // Browser / headless mode: derive from current location
21
+ const proto =
22
+ typeof location !== 'undefined' && location.protocol === 'https:'
23
+ ? 'wss:'
24
+ : 'ws:'
25
+ const host = typeof location !== 'undefined' ? location.host : '127.0.0.1'
26
+ url = `${proto}//${host}/ws`
27
  }
28
 
29
+ client = new WsRpcClient(url)
30
+ client.connect()
31
+ return client
32
+ }
33
+
34
+ // --- Environment helpers ---
35
+
36
+ const isTauriEnv = (): boolean =>
37
+ typeof window !== 'undefined' && !!(window as any).__TAURI_INTERNALS__
38
+
39
+ export const isTauri = isTauriEnv
40
 
41
+ export const isMacOS = (): boolean => {
42
+ if (typeof window === 'undefined') return false
43
+ return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
44
  }
45
 
46
+ // --- Progress bar ---
47
+
48
  export enum ProgressBarStatus {
49
  None = 'none',
50
  Normal = 'normal',
 
72
 
73
  return {
74
  async setProgressBar() {
 
75
  return
76
  },
77
  }
78
  }
79
 
80
+ // --- Window resize listener ---
81
+
82
  export async function listen<T>(
83
  event: string,
84
  handler: (event: { payload: T }) => void,
 
97
  return async () => {}
98
  }
99
 
100
+ // --- Window controls ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ export const windowControls = {
103
+ async minimize() {
104
+ if (isTauriEnv()) {
105
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
106
+ return getCurrentWindow().minimize()
107
+ }
108
+ },
109
+ async toggleMaximize() {
110
+ if (isTauriEnv()) {
111
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
112
+ return getCurrentWindow().toggleMaximize()
113
+ }
114
+ },
115
+ async close() {
116
+ if (isTauriEnv()) {
117
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
118
+ return getCurrentWindow().close()
119
+ }
120
+ },
121
+ async isMaximized(): Promise<boolean> {
122
+ if (isTauriEnv()) {
123
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
124
+ return getCurrentWindow().isMaximized()
125
+ }
126
+ return false
127
+ },
128
  }
129
 
130
+ // --- Typed RPC invoke ---
131
+
132
+ export async function invoke<M extends keyof RpcMethodMap>(
133
+ method: M,
134
+ ...args: RpcMethodMap[M][0] extends void ? [] : [RpcMethodMap[M][0]]
135
+ ): Promise<RpcMethodMap[M][1]> {
136
+ const params = args[0]
137
+
138
+ // Browser-only: open_external in a new tab
139
+ if (!isTauriEnv() && method === 'open_external') {
140
+ const p = params as { url: string }
141
+ if (p?.url) {
142
+ window.open(p.url, '_blank', 'noopener,noreferrer')
143
+ }
144
+ return undefined as RpcMethodMap[M][1]
145
+ }
146
 
147
+ // Special file-pick flow for open_documents
148
+ if (method === 'open_documents') {
149
+ return (await openDocumentsRpc()) as RpcMethodMap[M][1]
150
  }
151
 
152
+ // Special file-save flow for save_documents / export_document
153
+ if (method === 'save_documents' || method === 'export_document') {
154
+ const result = await getClient().invoke<FileResult>(method, params)
155
+ const blob = new Blob([toArrayBuffer(result.data)])
156
+ await saveFile(blob, result.filename)
157
+ return undefined as RpcMethodMap[M][1]
158
  }
159
 
160
+ return getClient().invoke<RpcMethodMap[M][1]>(method, params)
 
161
  }
162
 
163
+ async function openDocumentsRpc(): Promise<number> {
164
  const files = await pickFiles(
165
  [
166
  {
167
  description: 'Documents',
168
  accept: {
 
169
  'image/*': ['.png', '.jpg', '.jpeg', '.webp'],
170
  },
171
  },
172
  ],
173
  true,
174
  )
175
+ if (!files.length) return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ const entries = await Promise.all(
178
+ files.map(async (file) => ({
179
+ name: file.name,
180
+ data: new Uint8Array(await file.arrayBuffer()),
181
+ })),
182
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
+ return getClient().invoke<number>('open_documents', { files: entries })
 
 
 
185
  }
186
 
187
+ // --- Thumbnail fetch ---
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  export async function fetchThumbnail(index: number): Promise<Blob> {
190
+ const result = await getClient().invoke<{
191
+ data: Uint8Array
192
+ contentType: string
193
+ }>('get_thumbnail', { index })
194
+ return new Blob([toArrayBuffer(result.data)], {
195
+ type: result.contentType,
196
  })
 
 
 
 
197
  }
198
 
199
+ // --- Notification subscriptions ---
 
 
 
 
 
200
 
201
+ export type { DownloadProgress, ProcessProgress } from './rpc-types'
 
 
 
 
 
 
 
 
202
 
203
+ export function subscribeDownloadProgress(
204
+ cb: (p: RpcNotificationMap['download_progress']) => void,
 
205
  ): () => void {
206
+ return getClient().onNotification<RpcNotificationMap['download_progress']>(
207
+ 'download_progress',
208
+ cb,
209
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
 
212
+ export function subscribeProcessProgress(
213
+ cb: (p: RpcNotificationMap['process_progress']) => void,
214
+ ): () => void {
215
+ return getClient().onNotification<RpcNotificationMap['process_progress']>(
216
+ 'process_progress',
217
+ cb,
218
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
ui/lib/rpc-types.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Document, InpaintRegion, RenderEffect, TextBlock } from '@/types'
2
+
3
+ // Params → Result type map for all RPC methods
4
+
5
+ export type ThumbnailResult = {
6
+ data: Uint8Array
7
+ contentType: string
8
+ }
9
+
10
+ export type FileEntry = {
11
+ name: string
12
+ data: Uint8Array
13
+ }
14
+
15
+ export type FileResult = {
16
+ filename: string
17
+ data: Uint8Array
18
+ contentType: string
19
+ }
20
+
21
+ export type WgpuDeviceInfo = {
22
+ name: string
23
+ backend: string
24
+ deviceType: string
25
+ driver: string
26
+ driverInfo: string
27
+ }
28
+
29
+ export type DeviceInfo = {
30
+ mlDevice: string
31
+ wgpu: WgpuDeviceInfo
32
+ }
33
+
34
+ export type LlmModelInfo = {
35
+ id: string
36
+ languages: string[]
37
+ }
38
+
39
+ export type RpcMethodMap = {
40
+ app_version: [void, string]
41
+ device: [void, DeviceInfo]
42
+ open_external: [{ url: string }, void]
43
+ get_documents: [void, number]
44
+ get_document: [{ index: number }, Document]
45
+ get_thumbnail: [{ index: number }, ThumbnailResult]
46
+ open_documents: [void, number]
47
+ save_documents: [void, void]
48
+ export_document: [{ index: number }, void]
49
+ detect: [{ index: number }, void]
50
+ ocr: [{ index: number }, void]
51
+ inpaint: [{ index: number }, void]
52
+ update_inpaint_mask: [
53
+ { index: number; mask: Uint8Array; region?: InpaintRegion },
54
+ void,
55
+ ]
56
+ update_brush_layer: [
57
+ { index: number; patch: Uint8Array; region: InpaintRegion },
58
+ void,
59
+ ]
60
+ inpaint_partial: [{ index: number; region: InpaintRegion }, void]
61
+ render: [
62
+ {
63
+ index: number
64
+ textBlockIndex?: number
65
+ shaderEffect?: RenderEffect
66
+ },
67
+ void,
68
+ ]
69
+ update_text_blocks: [{ index: number; textBlocks: TextBlock[] }, void]
70
+ list_font_families: [void, string[]]
71
+ llm_list: [void, LlmModelInfo[]]
72
+ llm_load: [{ id: string }, void]
73
+ llm_offload: [void, void]
74
+ llm_ready: [void, boolean]
75
+ llm_generate: [
76
+ { index: number; textBlockIndex?: number; language?: string },
77
+ void,
78
+ ]
79
+ process: [
80
+ {
81
+ index?: number
82
+ llmModelId?: string
83
+ language?: string
84
+ shaderEffect?: RenderEffect
85
+ },
86
+ void,
87
+ ]
88
+ process_cancel: [void, void]
89
+ }
90
+
91
+ export type DownloadProgress = {
92
+ filename: string
93
+ downloaded: number
94
+ total?: number
95
+ status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
96
+ }
97
+
98
+ export type ProcessProgress = {
99
+ status: 'running' | 'completed' | 'cancelled' | { failed: string }
100
+ step: string | null
101
+ currentDocument: number
102
+ totalDocuments: number
103
+ currentStepIndex: number
104
+ totalSteps: number
105
+ overallPercent: number
106
+ }
107
+
108
+ export type RpcNotificationMap = {
109
+ download_progress: DownloadProgress
110
+ process_progress: ProcessProgress
111
+ }
ui/lib/store.ts CHANGED
@@ -60,7 +60,7 @@ const textBlockSyncer = createTextBlockSyncer()
60
  const createMaskSyncer = () => {
61
  type MaskUpdate = {
62
  index: number
63
- mask: number[]
64
  region?: InpaintRegion
65
  }
66
  let pending: MaskUpdate[] = []
@@ -176,15 +176,15 @@ type AppState = OperationSlice & {
176
  fetchAvailableFonts: () => Promise<void>
177
  updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
178
  updateMask: (
179
- mask: number[],
180
  options?: {
181
  sync?: boolean
182
- patch?: number[]
183
  patchRegion?: InpaintRegion
184
  },
185
  ) => Promise<void>
186
  paintRendered: (
187
- patch: number[],
188
  region: InpaintRegion,
189
  options?: { index?: number },
190
  ) => Promise<void>
@@ -265,7 +265,7 @@ export const useAppStore = create<AppState>((set, get) => {
265
 
266
  set({ currentDocumentLoading: true })
267
  try {
268
- const doc = await invoke<Document>('get_document', { index })
269
  // Only update if we're still on the same index
270
  if (get().currentDocumentIndex === index) {
271
  set({ currentDocument: doc, currentDocumentLoading: false })
@@ -283,7 +283,7 @@ export const useAppStore = create<AppState>((set, get) => {
283
  openDocuments: async () => {
284
  get().startOperation({ type: 'load-khr', cancellable: false })
285
  try {
286
- const count = await invoke<number>('open_documents')
287
  get().setTotalPages(count)
288
  } finally {
289
  get().finishOperation()
@@ -364,7 +364,7 @@ export const useAppStore = create<AppState>((set, get) => {
364
  setRenderEffect: (effect: RenderEffect) => set({ renderEffect: effect }),
365
  fetchAvailableFonts: async () => {
366
  try {
367
- const fonts = await invoke<string[]>('list_font_families')
368
  set({ availableFonts: fonts })
369
  } catch (_) {}
370
  },
@@ -484,7 +484,7 @@ export const useAppStore = create<AppState>((set, get) => {
484
  },
485
  llmList: async () => {
486
  try {
487
- const models = await invoke<LlmModelInfo[]>('llm_list')
488
  set({ llmModels: models })
489
  const currentModel = get().llmSelectedModel
490
  const currentLanguage = get().llmSelectedLanguage
@@ -574,7 +574,7 @@ export const useAppStore = create<AppState>((set, get) => {
574
  return
575
  }
576
  try {
577
- llmReadyCheckInFlight = invoke<boolean>('llm_ready')
578
  const ready = await llmReadyCheckInFlight
579
  set({ llmReady: ready })
580
  } catch (_) {}
 
60
  const createMaskSyncer = () => {
61
  type MaskUpdate = {
62
  index: number
63
+ mask: Uint8Array
64
  region?: InpaintRegion
65
  }
66
  let pending: MaskUpdate[] = []
 
176
  fetchAvailableFonts: () => Promise<void>
177
  updateTextBlocks: (textBlocks: TextBlock[]) => Promise<void>
178
  updateMask: (
179
+ mask: Uint8Array,
180
  options?: {
181
  sync?: boolean
182
+ patch?: Uint8Array
183
  patchRegion?: InpaintRegion
184
  },
185
  ) => Promise<void>
186
  paintRendered: (
187
+ patch: Uint8Array,
188
  region: InpaintRegion,
189
  options?: { index?: number },
190
  ) => Promise<void>
 
265
 
266
  set({ currentDocumentLoading: true })
267
  try {
268
+ const doc = await invoke('get_document', { index })
269
  // Only update if we're still on the same index
270
  if (get().currentDocumentIndex === index) {
271
  set({ currentDocument: doc, currentDocumentLoading: false })
 
283
  openDocuments: async () => {
284
  get().startOperation({ type: 'load-khr', cancellable: false })
285
  try {
286
+ const count = await invoke('open_documents')
287
  get().setTotalPages(count)
288
  } finally {
289
  get().finishOperation()
 
364
  setRenderEffect: (effect: RenderEffect) => set({ renderEffect: effect }),
365
  fetchAvailableFonts: async () => {
366
  try {
367
+ const fonts = await invoke('list_font_families')
368
  set({ availableFonts: fonts })
369
  } catch (_) {}
370
  },
 
484
  },
485
  llmList: async () => {
486
  try {
487
+ const models = await invoke('llm_list')
488
  set({ llmModels: models })
489
  const currentModel = get().llmSelectedModel
490
  const currentLanguage = get().llmSelectedLanguage
 
574
  return
575
  }
576
  try {
577
+ llmReadyCheckInFlight = invoke('llm_ready')
578
  const ready = await llmReadyCheckInFlight
579
  set({ llmReady: ready })
580
  } catch (_) {}
ui/lib/util.ts CHANGED
@@ -1,13 +1,21 @@
1
- export function convertToBlob(bytes: number[]): Blob {
2
- return new Blob([new Uint8Array(bytes)], { type: 'image/*' })
 
 
 
 
3
  }
4
 
5
- export function convertToImageBitmap(bytes: number[]): Promise<ImageBitmap> {
 
 
 
 
6
  const blob = convertToBlob(bytes)
7
  return createImageBitmap(blob)
8
  }
9
 
10
- export async function blobToUint8Array(blob: Blob): Promise<number[]> {
11
  const buffer = await blob.arrayBuffer()
12
- return Array.from(new Uint8Array(buffer))
13
  }
 
1
+ /** Extract a standalone ArrayBuffer from a Uint8Array view (msgpack may return views into a shared decode buffer). */
2
+ export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
3
+ return bytes.buffer.slice(
4
+ bytes.byteOffset,
5
+ bytes.byteOffset + bytes.byteLength,
6
+ ) as ArrayBuffer
7
  }
8
 
9
+ export function convertToBlob(bytes: Uint8Array): Blob {
10
+ return new Blob([toArrayBuffer(bytes)])
11
+ }
12
+
13
+ export function convertToImageBitmap(bytes: Uint8Array): Promise<ImageBitmap> {
14
  const blob = convertToBlob(bytes)
15
  return createImageBitmap(blob)
16
  }
17
 
18
+ export async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
19
  const buffer = await blob.arrayBuffer()
20
+ return new Uint8Array(buffer)
21
  }
ui/lib/ws.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { encode, decode } from '@msgpack/msgpack'
4
+
5
+ type OutgoingRequest = {
6
+ type: 'req'
7
+ id: number
8
+ method: string
9
+ params?: unknown
10
+ }
11
+
12
+ type IncomingResponse = {
13
+ type: 'res'
14
+ id: number
15
+ result?: unknown
16
+ error?: string
17
+ }
18
+
19
+ type IncomingNotification = {
20
+ type: 'ntf'
21
+ method: string
22
+ params: unknown
23
+ }
24
+
25
+ type IncomingMessage = IncomingResponse | IncomingNotification
26
+
27
+ type PendingRequest = {
28
+ resolve: (value: unknown) => void
29
+ reject: (reason: Error) => void
30
+ }
31
+
32
+ export class WsRpcClient {
33
+ private ws: WebSocket | null = null
34
+ private nextId = 1
35
+ private pending = new Map<number, PendingRequest>()
36
+ private notificationHandlers = new Map<
37
+ string,
38
+ Set<(params: unknown) => void>
39
+ >()
40
+ private url: string
41
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
42
+ private closed = false
43
+ private readyQueue: Array<() => void> = []
44
+
45
+ constructor(url: string) {
46
+ this.url = url
47
+ }
48
+
49
+ connect(): void {
50
+ if (this.ws) return
51
+ this.closed = false
52
+
53
+ const ws = new WebSocket(this.url)
54
+ ws.binaryType = 'arraybuffer'
55
+
56
+ ws.onopen = () => {
57
+ if (this.reconnectTimer) {
58
+ clearTimeout(this.reconnectTimer)
59
+ this.reconnectTimer = null
60
+ }
61
+ const queue = this.readyQueue.splice(0)
62
+ for (const fn of queue) fn()
63
+ }
64
+
65
+ ws.onmessage = (event: MessageEvent) => {
66
+ if (!(event.data instanceof ArrayBuffer)) return
67
+ let msg: IncomingMessage
68
+ try {
69
+ msg = decode(new Uint8Array(event.data)) as IncomingMessage
70
+ } catch {
71
+ return
72
+ }
73
+
74
+ if (msg.type === 'res') {
75
+ const p = this.pending.get(msg.id)
76
+ if (p) {
77
+ this.pending.delete(msg.id)
78
+ if (msg.error) {
79
+ p.reject(new Error(msg.error))
80
+ } else {
81
+ p.resolve(msg.result)
82
+ }
83
+ }
84
+ } else if (msg.type === 'ntf') {
85
+ const handlers = this.notificationHandlers.get(msg.method)
86
+ if (handlers) {
87
+ for (const handler of handlers) {
88
+ try {
89
+ handler(msg.params)
90
+ } catch {}
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ ws.onclose = () => {
97
+ this.ws = null
98
+ // Reject queued and pending requests
99
+ const queue = this.readyQueue.splice(0)
100
+ for (const fn of queue) fn()
101
+ for (const [, p] of this.pending) {
102
+ p.reject(new Error('WebSocket closed'))
103
+ }
104
+ this.pending.clear()
105
+
106
+ if (!this.closed) {
107
+ this.scheduleReconnect()
108
+ }
109
+ }
110
+
111
+ ws.onerror = () => {
112
+ // onclose will fire after this
113
+ }
114
+
115
+ this.ws = ws
116
+ }
117
+
118
+ close(): void {
119
+ this.closed = true
120
+ if (this.reconnectTimer) {
121
+ clearTimeout(this.reconnectTimer)
122
+ this.reconnectTimer = null
123
+ }
124
+ this.ws?.close()
125
+ this.ws = null
126
+ }
127
+
128
+ invoke<T = unknown>(method: string, params?: unknown): Promise<T> {
129
+ return new Promise((resolve, reject) => {
130
+ const send = () => {
131
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
132
+ reject(new Error('WebSocket not connected'))
133
+ return
134
+ }
135
+
136
+ const id = this.nextId++
137
+ this.pending.set(id, {
138
+ resolve: resolve as (v: unknown) => void,
139
+ reject,
140
+ })
141
+
142
+ const msg: OutgoingRequest = { type: 'req', id, method }
143
+ if (params !== undefined) {
144
+ msg.params = params
145
+ }
146
+
147
+ const bytes = encode(msg)
148
+ this.ws.send(bytes)
149
+ }
150
+
151
+ if (this.ws?.readyState === WebSocket.CONNECTING) {
152
+ this.readyQueue.push(send)
153
+ } else {
154
+ send()
155
+ }
156
+ })
157
+ }
158
+
159
+ onNotification<T = unknown>(
160
+ method: string,
161
+ handler: (params: T) => void,
162
+ ): () => void {
163
+ let handlers = this.notificationHandlers.get(method)
164
+ if (!handlers) {
165
+ handlers = new Set()
166
+ this.notificationHandlers.set(method, handlers)
167
+ }
168
+ const wrapped = handler as (params: unknown) => void
169
+ handlers.add(wrapped)
170
+ return () => {
171
+ handlers!.delete(wrapped)
172
+ if (handlers!.size === 0) {
173
+ this.notificationHandlers.delete(method)
174
+ }
175
+ }
176
+ }
177
+
178
+ get connected(): boolean {
179
+ return this.ws?.readyState === WebSocket.OPEN
180
+ }
181
+
182
+ private scheduleReconnect(): void {
183
+ if (this.reconnectTimer) return
184
+ this.reconnectTimer = setTimeout(() => {
185
+ this.reconnectTimer = null
186
+ this.connect()
187
+ }, 1000)
188
+ }
189
+ }
ui/package.json CHANGED
@@ -39,6 +39,7 @@
39
  "react-rnd": "^10.5.2",
40
  "remark-gfm": "^4.0.1",
41
  "tailwind-merge": "^3.4.0",
 
42
  "zustand": "^5.0.11"
43
  },
44
  "devDependencies": {
 
39
  "react-rnd": "^10.5.2",
40
  "remark-gfm": "^4.0.1",
41
  "tailwind-merge": "^3.4.0",
42
+ "@msgpack/msgpack": "^3.0.0-beta.3",
43
  "zustand": "^5.0.11"
44
  },
45
  "devDependencies": {
ui/types.d.ts CHANGED
@@ -36,7 +36,7 @@ export type TextBlock = {
36
  translation?: string
37
  style?: TextStyle
38
  fontPrediction?: FontPrediction
39
- rendered?: number[]
40
  }
41
 
42
  export type ToolMode = 'select' | 'block' | 'brush' | 'repairBrush' | 'eraser'
@@ -52,12 +52,12 @@ export type Document = {
52
  id: string
53
  path: string
54
  name: string
55
- image: number[]
56
  width: number
57
  height: number
58
  textBlocks: TextBlock[]
59
- segment?: number[]
60
- inpainted?: number[]
61
- brushLayer?: number[]
62
- rendered?: number[]
63
  }
 
36
  translation?: string
37
  style?: TextStyle
38
  fontPrediction?: FontPrediction
39
+ rendered?: Uint8Array
40
  }
41
 
42
  export type ToolMode = 'select' | 'block' | 'brush' | 'repairBrush' | 'eraser'
 
52
  id: string
53
  path: string
54
  name: string
55
+ image: Uint8Array
56
  width: number
57
  height: number
58
  textBlocks: TextBlock[]
59
+ segment?: Uint8Array
60
+ inpainted?: Uint8Array
61
+ brushLayer?: Uint8Array
62
+ rendered?: Uint8Array
63
  }