Mayo commited on
Commit
dab69e9
ยท
unverified ยท
1 Parent(s): c7953db

feat: Codex integration

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. Cargo.lock +3 -0
  2. koharu-ai/Cargo.toml +1 -0
  3. koharu-ai/bin/codex.rs +6 -74
  4. koharu-ai/src/codex/client.rs +37 -0
  5. koharu-ai/src/codex/image.rs +385 -0
  6. koharu-ai/src/codex/mod.rs +2 -1
  7. koharu-ai/src/lib.rs +3 -0
  8. koharu-ai/src/provider.rs +50 -0
  9. koharu-app/Cargo.toml +2 -0
  10. koharu-app/src/ai.rs +431 -0
  11. koharu-app/src/app.rs +4 -0
  12. koharu-app/src/lib.rs +2 -0
  13. koharu-rpc/src/api.rs +1 -0
  14. koharu-rpc/src/routes/ai.rs +133 -0
  15. koharu-rpc/src/routes/mod.rs +1 -0
  16. koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap +24 -0
  17. ui/components/Panels.tsx +56 -3
  18. ui/components/SettingsDialog.tsx +193 -0
  19. ui/components/canvas/SubToolRail.tsx +72 -72
  20. ui/components/panels/AiPanel.tsx +99 -0
  21. ui/lib/api/default/default.msw.ts +162 -1
  22. ui/lib/api/default/default.ts +312 -1
  23. ui/lib/api/schemas/addImageLayerResponse.ts +1 -1
  24. ui/lib/api/schemas/appConfig.ts +1 -1
  25. ui/lib/api/schemas/appEvent.ts +1 -1
  26. ui/lib/api/schemas/blobRef.ts +1 -1
  27. ui/lib/api/schemas/codexAuthAttemptStatus.ts +14 -0
  28. ui/lib/api/schemas/codexAuthStatus.ts +13 -0
  29. ui/lib/api/schemas/codexDeviceLogin.ts +15 -0
  30. ui/lib/api/schemas/codexDeviceLoginStatus.ts +15 -0
  31. ui/lib/api/schemas/codexImageGenerationOptions.ts +19 -0
  32. ui/lib/api/schemas/codexImageGenerationResponse.ts +9 -0
  33. ui/lib/api/schemas/configPatch.ts +1 -1
  34. ui/lib/api/schemas/createPagesFromPathsRequest.ts +1 -1
  35. ui/lib/api/schemas/createPagesResponse.ts +1 -1
  36. ui/lib/api/schemas/createProjectRequest.ts +1 -1
  37. ui/lib/api/schemas/dataConfig.ts +1 -1
  38. ui/lib/api/schemas/dataConfigPatch.ts +1 -1
  39. ui/lib/api/schemas/downloadProgress.ts +1 -1
  40. ui/lib/api/schemas/downloadStatus.ts +1 -1
  41. ui/lib/api/schemas/engineCatalog.ts +1 -1
  42. ui/lib/api/schemas/engineCatalogEntry.ts +1 -1
  43. ui/lib/api/schemas/exportFormat.ts +1 -1
  44. ui/lib/api/schemas/exportProjectRequest.ts +1 -1
  45. ui/lib/api/schemas/fontFaceInfo.ts +1 -1
  46. ui/lib/api/schemas/fontPrediction.ts +1 -1
  47. ui/lib/api/schemas/fontSource.ts +1 -1
  48. ui/lib/api/schemas/googleFontCatalog.ts +1 -1
  49. ui/lib/api/schemas/googleFontEntry.ts +1 -1
  50. ui/lib/api/schemas/googleFontVariant.ts +1 -1
Cargo.lock CHANGED
@@ -4514,6 +4514,7 @@ name = "koharu-ai"
4514
  version = "0.48.0"
4515
  dependencies = [
4516
  "anyhow",
 
4517
  "base64 0.22.1",
4518
  "clap",
4519
  "eventsource-stream",
@@ -4535,6 +4536,7 @@ dependencies = [
4535
  "arc-swap",
4536
  "async-trait",
4537
  "atomicwrites",
 
4538
  "blake3",
4539
  "camino",
4540
  "chrono",
@@ -4546,6 +4548,7 @@ dependencies = [
4546
  "imageproc",
4547
  "indexmap 2.14.0",
4548
  "inventory",
 
4549
  "koharu-core",
4550
  "koharu-llm",
4551
  "koharu-ml",
 
4514
  version = "0.48.0"
4515
  dependencies = [
4516
  "anyhow",
4517
+ "async-trait",
4518
  "base64 0.22.1",
4519
  "clap",
4520
  "eventsource-stream",
 
4536
  "arc-swap",
4537
  "async-trait",
4538
  "atomicwrites",
4539
+ "base64 0.22.1",
4540
  "blake3",
4541
  "camino",
4542
  "chrono",
 
4548
  "imageproc",
4549
  "indexmap 2.14.0",
4550
  "inventory",
4551
+ "koharu-ai",
4552
  "koharu-core",
4553
  "koharu-llm",
4554
  "koharu-ml",
koharu-ai/Cargo.toml CHANGED
@@ -17,6 +17,7 @@ path = "bin/codex.rs"
17
 
18
  [dependencies]
19
  anyhow = { workspace = true }
 
20
  base64 = { workspace = true }
21
  clap = { workspace = true }
22
  eventsource-stream = { workspace = true }
 
17
 
18
  [dependencies]
19
  anyhow = { workspace = true }
20
+ async-trait = { workspace = true }
21
  base64 = { workspace = true }
22
  clap = { workspace = true }
23
  eventsource-stream = { workspace = true }
koharu-ai/bin/codex.rs CHANGED
@@ -7,7 +7,7 @@ use eventsource_stream::Eventsource;
7
  use futures::StreamExt;
8
  use koharu_ai::codex::{
9
  CodexClient, CodexConfig, CodexImageGenerationRequest, CodexInputImage, CodexTaskRequest,
10
- DEFAULT_RESPONSES_URL,
11
  };
12
  use serde_json::Value;
13
 
@@ -207,8 +207,10 @@ async fn image_cmd(client: &CodexClient, command: ImageCommand) -> anyhow::Resul
207
  }
208
 
209
  let response = client.create_response_raw(&request).await?;
210
- let Some(url) = image_response_stream_url(response).await? else {
211
- anyhow::bail!("No image URL or image result found in response stream");
 
 
212
  };
213
  println!("{url}");
214
  Ok(())
@@ -252,77 +254,6 @@ async fn print_response_stream(response: reqwest::Response) -> anyhow::Result<()
252
  Ok(())
253
  }
254
 
255
- async fn image_response_stream_url(response: reqwest::Response) -> anyhow::Result<Option<String>> {
256
- let mut stream = response.bytes_stream().eventsource();
257
- let mut result = None;
258
-
259
- while let Some(event) = stream.next().await {
260
- let event = event?;
261
- let Ok(data) = serde_json::from_str::<Value>(&event.data) else {
262
- continue;
263
- };
264
- if let Some(url) = extract_image_url(&data) {
265
- result = Some(url);
266
- }
267
- }
268
-
269
- Ok(result)
270
- }
271
-
272
- fn extract_image_url(value: &Value) -> Option<String> {
273
- match value {
274
- Value::Object(map) => {
275
- if matches!(
276
- map.get("type").and_then(Value::as_str),
277
- Some("image_generation_call")
278
- ) && let Some(url) = extract_image_result(map.get("result")?)
279
- {
280
- return Some(url);
281
- }
282
-
283
- if let Some(call) = map.get("image_generation_call")
284
- && let Some(url) = extract_image_url(call)
285
- {
286
- return Some(url);
287
- }
288
-
289
- if let Some(url) = map.get("url").and_then(Value::as_str)
290
- && (url.starts_with("http://")
291
- || url.starts_with("https://")
292
- || url.starts_with("data:image/"))
293
- {
294
- return Some(url.to_string());
295
- }
296
-
297
- for child in map.values() {
298
- if let Some(url) = extract_image_url(child) {
299
- return Some(url);
300
- }
301
- }
302
- None
303
- }
304
- Value::Array(items) => items.iter().find_map(extract_image_url),
305
- _ => None,
306
- }
307
- }
308
-
309
- fn extract_image_result(value: &Value) -> Option<String> {
310
- match value {
311
- Value::String(value) if value.starts_with("http://") || value.starts_with("https://") => {
312
- Some(value.clone())
313
- }
314
- Value::String(value) if value.starts_with("data:image/") => Some(value.clone()),
315
- Value::String(value) if !value.is_empty() => Some(format!("data:image/png;base64,{value}")),
316
- Value::Object(map) => map
317
- .get("url")
318
- .and_then(Value::as_str)
319
- .map(ToOwned::to_owned)
320
- .or_else(|| map.values().find_map(extract_image_url)),
321
- Value::Array(items) => items.iter().find_map(extract_image_url),
322
- _ => None,
323
- }
324
- }
325
-
326
  fn image_data_url(path: &Path) -> anyhow::Result<String> {
327
  let bytes = std::fs::read(path)?;
328
  let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
@@ -346,6 +277,7 @@ fn image_mime_type(path: &Path) -> &'static str {
346
  #[cfg(test)]
347
  mod tests {
348
  use super::*;
 
349
 
350
  #[test]
351
  fn extracts_nested_image_generation_url() {
 
7
  use futures::StreamExt;
8
  use koharu_ai::codex::{
9
  CodexClient, CodexConfig, CodexImageGenerationRequest, CodexInputImage, CodexTaskRequest,
10
+ DEFAULT_RESPONSES_URL, image_response_stream_result,
11
  };
12
  use serde_json::Value;
13
 
 
207
  }
208
 
209
  let response = client.create_response_raw(&request).await?;
210
+ let result = image_response_stream_result(response).await?;
211
+ let Some(url) = result.image_url else {
212
+ let response_text = result.response_text.as_deref().unwrap_or("none");
213
+ anyhow::bail!("No image URL or image result found in response stream: {response_text}");
214
  };
215
  println!("{url}");
216
  Ok(())
 
254
  Ok(())
255
  }
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  fn image_data_url(path: &Path) -> anyhow::Result<String> {
258
  let bytes = std::fs::read(path)?;
259
  let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
 
277
  #[cfg(test)]
278
  mod tests {
279
  use super::*;
280
+ use koharu_ai::codex::extract_image_url;
281
 
282
  #[test]
283
  fn extracts_nested_image_generation_url() {
koharu-ai/src/codex/client.rs CHANGED
@@ -9,12 +9,14 @@ use serde_json::Value;
9
  use super::config::CodexConfig;
10
  use super::device::{DeviceAuthorization, DeviceCode};
11
  use super::error::{CodexError, Result};
 
12
  use super::requests::{
13
  TokenExchangeRequest, TokenExchangeResponse, TokenPollRequest, TokenPollSuccessResponse,
14
  TokenRefreshRequest, TokenRefreshResponse, UserCodeRequest, UserCodeResponse,
15
  };
16
  use super::token_store::TokenStore;
17
  use super::tokens::CodexTokens;
 
18
 
19
  const USER_AGENT: &str = concat!("koharu-ai/", env!("CARGO_PKG_VERSION"));
20
 
@@ -306,3 +308,38 @@ async fn ensure_success(endpoint: &str, response: reqwest::Response) -> Result<r
306
  body,
307
  })
308
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  use super::config::CodexConfig;
10
  use super::device::{DeviceAuthorization, DeviceCode};
11
  use super::error::{CodexError, Result};
12
+ use super::image::{CodexImageGenerationRequest, CodexInputImage, image_response_stream_result};
13
  use super::requests::{
14
  TokenExchangeRequest, TokenExchangeResponse, TokenPollRequest, TokenPollSuccessResponse,
15
  TokenRefreshRequest, TokenRefreshResponse, UserCodeRequest, UserCodeResponse,
16
  };
17
  use super::token_store::TokenStore;
18
  use super::tokens::CodexTokens;
19
+ use crate::provider::{AiImageProvider, AiImageRequest, AiImageResult};
20
 
21
  const USER_AGENT: &str = concat!("koharu-ai/", env!("CARGO_PKG_VERSION"));
22
 
 
308
  body,
309
  })
310
  }
311
+
312
+ #[async_trait::async_trait]
313
+ impl AiImageProvider for CodexClient {
314
+ async fn generate_image(&self, request: AiImageRequest) -> anyhow::Result<AiImageResult> {
315
+ let action = request.action.unwrap_or_else(|| {
316
+ if request.input_image.is_some() {
317
+ "edit".to_string()
318
+ } else {
319
+ "generate".to_string()
320
+ }
321
+ });
322
+
323
+ let mut codex_request = CodexImageGenerationRequest::new(request.model, request.prompt)
324
+ .with_instructions(request.instructions)
325
+ .with_quality(request.quality)
326
+ .with_action(action);
327
+ if let Some(size) = request.size {
328
+ codex_request = codex_request.with_size(size);
329
+ }
330
+ if let Some(image) = request.input_image {
331
+ codex_request = codex_request
332
+ .with_input_image(CodexInputImage::new(image.data_url).with_detail(image.detail));
333
+ }
334
+
335
+ let response = self.create_response_raw(&codex_request).await?;
336
+ let result = image_response_stream_result(response).await?;
337
+ let image_url = result.image_url.ok_or_else(|| {
338
+ let response_text = result.response_text.as_deref().unwrap_or("none");
339
+ anyhow::anyhow!(
340
+ "Codex returned no image URL or image result. Response text: {response_text}"
341
+ )
342
+ })?;
343
+ Ok(AiImageResult { image_url })
344
+ }
345
+ }
koharu-ai/src/codex/image.rs CHANGED
@@ -1,4 +1,8 @@
1
  use serde::Serialize;
 
 
 
 
2
 
3
  use super::responses::{CodexInputContent, CodexInputItem};
4
 
@@ -10,11 +14,18 @@ pub struct CodexImageGenerationRequest {
10
  pub model: String,
11
  pub instructions: String,
12
  pub tools: [CodexImageGenerationTool; 1],
 
13
  pub input: Vec<CodexInputItem>,
14
  pub stream: bool,
15
  pub store: bool,
16
  }
17
 
 
 
 
 
 
 
18
  #[derive(Debug, Clone, Serialize)]
19
  pub struct CodexImageGenerationTool {
20
  #[serde(rename = "type")]
@@ -38,12 +49,19 @@ pub struct CodexInputImage {
38
  pub detail: String,
39
  }
40
 
 
 
 
 
 
 
41
  impl CodexImageGenerationRequest {
42
  pub fn new(model: impl Into<String>, prompt: impl Into<String>) -> Self {
43
  Self {
44
  model: model.into(),
45
  instructions: DEFAULT_IMAGE_INSTRUCTIONS.to_string(),
46
  tools: [CodexImageGenerationTool::default()],
 
47
  input: vec![CodexInputItem::user_text(prompt)],
48
  stream: true,
49
  store: false,
@@ -85,6 +103,14 @@ impl CodexImageGenerationRequest {
85
  }
86
  }
87
 
 
 
 
 
 
 
 
 
88
  impl CodexImageGenerationTool {
89
  pub fn new(image_generation: CodexImageGenerationConfig) -> Self {
90
  Self {
@@ -124,6 +150,236 @@ impl CodexInputImage {
124
  }
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  #[cfg(test)]
128
  mod tests {
129
  use super::*;
@@ -145,6 +401,7 @@ mod tests {
145
  "draw a koharu logo"
146
  );
147
  assert_eq!(value["tools"][0]["type"], "image_generation");
 
148
  assert_eq!(value["tools"][0]["quality"], "high");
149
  assert_eq!(value["tools"][0]["action"], "generate");
150
  assert_eq!(value["stream"], true);
@@ -168,6 +425,134 @@ mod tests {
168
  );
169
  assert_eq!(value["input"][0]["content"][1]["detail"], "high");
170
  assert_eq!(value["tools"][0]["action"], "edit");
 
171
  assert!(value.get("input_image").is_none());
172
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  }
 
1
  use serde::Serialize;
2
+ use serde_json::Value;
3
+
4
+ use eventsource_stream::Eventsource;
5
+ use futures::StreamExt;
6
 
7
  use super::responses::{CodexInputContent, CodexInputItem};
8
 
 
14
  pub model: String,
15
  pub instructions: String,
16
  pub tools: [CodexImageGenerationTool; 1],
17
+ pub tool_choice: CodexImageToolChoice,
18
  pub input: Vec<CodexInputItem>,
19
  pub stream: bool,
20
  pub store: bool,
21
  }
22
 
23
+ #[derive(Debug, Clone, Serialize)]
24
+ pub struct CodexImageToolChoice {
25
+ #[serde(rename = "type")]
26
+ pub tool_type: &'static str,
27
+ }
28
+
29
  #[derive(Debug, Clone, Serialize)]
30
  pub struct CodexImageGenerationTool {
31
  #[serde(rename = "type")]
 
49
  pub detail: String,
50
  }
51
 
52
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
53
+ pub struct CodexImageStreamResult {
54
+ pub image_url: Option<String>,
55
+ pub response_text: Option<String>,
56
+ }
57
+
58
  impl CodexImageGenerationRequest {
59
  pub fn new(model: impl Into<String>, prompt: impl Into<String>) -> Self {
60
  Self {
61
  model: model.into(),
62
  instructions: DEFAULT_IMAGE_INSTRUCTIONS.to_string(),
63
  tools: [CodexImageGenerationTool::default()],
64
+ tool_choice: CodexImageToolChoice::image_generation(),
65
  input: vec![CodexInputItem::user_text(prompt)],
66
  stream: true,
67
  store: false,
 
103
  }
104
  }
105
 
106
+ impl CodexImageToolChoice {
107
+ pub fn image_generation() -> Self {
108
+ Self {
109
+ tool_type: "image_generation",
110
+ }
111
+ }
112
+ }
113
+
114
  impl CodexImageGenerationTool {
115
  pub fn new(image_generation: CodexImageGenerationConfig) -> Self {
116
  Self {
 
150
  }
151
  }
152
 
153
+ pub async fn image_response_stream_url(
154
+ response: reqwest::Response,
155
+ ) -> anyhow::Result<Option<String>> {
156
+ Ok(image_response_stream_result(response).await?.image_url)
157
+ }
158
+
159
+ pub async fn image_response_stream_result(
160
+ response: reqwest::Response,
161
+ ) -> anyhow::Result<CodexImageStreamResult> {
162
+ let mut stream = response.bytes_stream().eventsource();
163
+ let mut collector = CodexImageStreamCollector::default();
164
+
165
+ while let Some(event) = stream.next().await {
166
+ let event = event?;
167
+ let Ok(data) = serde_json::from_str::<Value>(&event.data) else {
168
+ continue;
169
+ };
170
+ collector.push(&data)?;
171
+ }
172
+
173
+ Ok(collector.finish())
174
+ }
175
+
176
+ #[derive(Debug, Default)]
177
+ struct CodexImageStreamCollector {
178
+ final_image: Option<String>,
179
+ partial_image: Option<String>,
180
+ output_text: Vec<String>,
181
+ final_text: Option<String>,
182
+ }
183
+
184
+ impl CodexImageStreamCollector {
185
+ fn push(&mut self, value: &Value) -> anyhow::Result<()> {
186
+ if let Some(error) = extract_response_error(value) {
187
+ anyhow::bail!("{error}");
188
+ }
189
+
190
+ if let Some(url) = extract_final_image_url(value) {
191
+ self.final_image = Some(url);
192
+ }
193
+ if let Some(url) = extract_partial_image_url(value) {
194
+ self.partial_image = Some(url);
195
+ }
196
+ if let Some(delta) = extract_output_text_delta(value) {
197
+ self.output_text.push(delta);
198
+ }
199
+ if let Some(text) = extract_response_text(value) {
200
+ self.final_text = Some(text);
201
+ }
202
+
203
+ Ok(())
204
+ }
205
+
206
+ fn finish(self) -> CodexImageStreamResult {
207
+ CodexImageStreamResult {
208
+ image_url: self.final_image.or(self.partial_image),
209
+ response_text: self
210
+ .final_text
211
+ .or_else(|| join_text_fragments(self.output_text)),
212
+ }
213
+ }
214
+ }
215
+
216
+ pub fn extract_image_url(value: &Value) -> Option<String> {
217
+ extract_final_image_url(value).or_else(|| extract_partial_image_url(value))
218
+ }
219
+
220
+ fn extract_final_image_url(value: &Value) -> Option<String> {
221
+ find_map_value(value, &mut |value| {
222
+ let Value::Object(map) = value else {
223
+ return None;
224
+ };
225
+
226
+ if matches!(
227
+ map.get("type").and_then(Value::as_str),
228
+ Some("image_generation_call")
229
+ ) {
230
+ return map.get("result").and_then(extract_image_result);
231
+ }
232
+
233
+ map.get("image_generation_call")
234
+ .and_then(extract_final_image_url)
235
+ .or_else(|| {
236
+ map.get("url")
237
+ .or_else(|| map.get("image_url"))
238
+ .and_then(Value::as_str)
239
+ .filter(|url| is_image_url(url))
240
+ .map(ToOwned::to_owned)
241
+ })
242
+ })
243
+ }
244
+
245
+ fn extract_partial_image_url(value: &Value) -> Option<String> {
246
+ let Value::Object(map) = value else {
247
+ return None;
248
+ };
249
+
250
+ let b64 = map
251
+ .get("partial_image_b64")
252
+ .or_else(|| map.get("b64_json"))
253
+ .and_then(Value::as_str)
254
+ .filter(|value| !value.trim().is_empty())?;
255
+ Some(format!("data:image/png;base64,{b64}"))
256
+ }
257
+
258
+ fn extract_image_result(value: &Value) -> Option<String> {
259
+ match value {
260
+ Value::String(value) if value.starts_with("http://") || value.starts_with("https://") => {
261
+ Some(value.clone())
262
+ }
263
+ Value::String(value) if value.starts_with("data:image/") => Some(value.clone()),
264
+ Value::String(value) if !value.is_empty() => Some(format!("data:image/png;base64,{value}")),
265
+ Value::Object(map) => map
266
+ .get("url")
267
+ .and_then(Value::as_str)
268
+ .filter(|url| is_image_url(url))
269
+ .map(ToOwned::to_owned)
270
+ .or_else(|| map.values().find_map(extract_final_image_url)),
271
+ Value::Array(items) => items.iter().find_map(extract_final_image_url),
272
+ _ => None,
273
+ }
274
+ }
275
+
276
+ fn is_image_url(value: &str) -> bool {
277
+ value.starts_with("http://")
278
+ || value.starts_with("https://")
279
+ || value.starts_with("data:image/")
280
+ }
281
+
282
+ fn extract_output_text_delta(value: &Value) -> Option<String> {
283
+ let Value::Object(map) = value else {
284
+ return None;
285
+ };
286
+ if !matches!(
287
+ map.get("type").and_then(Value::as_str),
288
+ Some("response.output_text.delta")
289
+ ) {
290
+ return None;
291
+ }
292
+ map.get("delta")
293
+ .and_then(Value::as_str)
294
+ .filter(|text| !text.is_empty())
295
+ .map(ToOwned::to_owned)
296
+ }
297
+
298
+ fn extract_response_text(value: &Value) -> Option<String> {
299
+ let mut fragments = Vec::new();
300
+ collect_response_text(value, &mut fragments);
301
+ join_text_fragments(fragments)
302
+ }
303
+
304
+ fn collect_response_text(value: &Value, fragments: &mut Vec<String>) {
305
+ match value {
306
+ Value::Object(map) => {
307
+ if matches!(
308
+ map.get("type").and_then(Value::as_str),
309
+ Some("response.output_text.done" | "output_text")
310
+ ) && let Some(text) = map.get("text").and_then(Value::as_str)
311
+ && !text.is_empty()
312
+ {
313
+ fragments.push(text.to_string());
314
+ return;
315
+ }
316
+
317
+ if matches!(map.get("type").and_then(Value::as_str), Some("message"))
318
+ && !matches!(map.get("role").and_then(Value::as_str), Some("assistant"))
319
+ {
320
+ return;
321
+ }
322
+
323
+ for key in ["response", "output", "item", "content"] {
324
+ if let Some(child) = map.get(key) {
325
+ collect_response_text(child, fragments);
326
+ }
327
+ }
328
+ }
329
+ Value::Array(items) => {
330
+ for item in items {
331
+ collect_response_text(item, fragments);
332
+ }
333
+ }
334
+ _ => {}
335
+ }
336
+ }
337
+
338
+ fn join_text_fragments(fragments: Vec<String>) -> Option<String> {
339
+ let text = fragments.concat();
340
+ let text = text.trim();
341
+ if text.is_empty() {
342
+ None
343
+ } else {
344
+ Some(text.to_string())
345
+ }
346
+ }
347
+
348
+ fn find_map_value(value: &Value, f: &mut impl FnMut(&Value) -> Option<String>) -> Option<String> {
349
+ if let Some(found) = f(value) {
350
+ return Some(found);
351
+ }
352
+
353
+ match value {
354
+ Value::Object(map) => map.values().find_map(|child| find_map_value(child, f)),
355
+ Value::Array(items) => items.iter().find_map(|child| find_map_value(child, f)),
356
+ _ => None,
357
+ }
358
+ }
359
+
360
+ fn extract_response_error(value: &Value) -> Option<String> {
361
+ let Value::Object(map) = value else {
362
+ return None;
363
+ };
364
+ let event_type = map.get("type").and_then(Value::as_str);
365
+ if !matches!(
366
+ event_type,
367
+ Some("response.failed" | "response.incomplete" | "error")
368
+ ) {
369
+ return None;
370
+ }
371
+
372
+ map.get("error")
373
+ .and_then(|error| {
374
+ error
375
+ .get("message")
376
+ .and_then(Value::as_str)
377
+ .or_else(|| error.as_str())
378
+ })
379
+ .or_else(|| map.get("message").and_then(Value::as_str))
380
+ .map(ToOwned::to_owned)
381
+ }
382
+
383
  #[cfg(test)]
384
  mod tests {
385
  use super::*;
 
401
  "draw a koharu logo"
402
  );
403
  assert_eq!(value["tools"][0]["type"], "image_generation");
404
+ assert_eq!(value["tool_choice"]["type"], "image_generation");
405
  assert_eq!(value["tools"][0]["quality"], "high");
406
  assert_eq!(value["tools"][0]["action"], "generate");
407
  assert_eq!(value["stream"], true);
 
425
  );
426
  assert_eq!(value["input"][0]["content"][1]["detail"], "high");
427
  assert_eq!(value["tools"][0]["action"], "edit");
428
+ assert_eq!(value["tool_choice"]["type"], "image_generation");
429
  assert!(value.get("input_image").is_none());
430
  }
431
+
432
+ #[test]
433
+ fn extracts_nested_image_generation_url() {
434
+ let value = serde_json::json!({
435
+ "type": "response.output_item.done",
436
+ "item": {
437
+ "type": "image_generation_call",
438
+ "result": {
439
+ "url": "https://example.test/image.png"
440
+ }
441
+ }
442
+ });
443
+
444
+ assert_eq!(
445
+ extract_image_url(&value),
446
+ Some("https://example.test/image.png".to_string())
447
+ );
448
+ }
449
+
450
+ #[test]
451
+ fn converts_base64_image_generation_result_to_data_url() {
452
+ let value = serde_json::json!({
453
+ "type": "image_generation_call",
454
+ "result": "abc123"
455
+ });
456
+
457
+ assert_eq!(
458
+ extract_image_url(&value),
459
+ Some("data:image/png;base64,abc123".to_string())
460
+ );
461
+ }
462
+
463
+ #[test]
464
+ fn extracts_responses_partial_image_event() {
465
+ let value = serde_json::json!({
466
+ "type": "response.image_generation_call.partial_image",
467
+ "partial_image_b64": "abc123"
468
+ });
469
+
470
+ assert_eq!(
471
+ extract_image_url(&value),
472
+ Some("data:image/png;base64,abc123".to_string())
473
+ );
474
+ }
475
+
476
+ #[test]
477
+ fn extracts_images_stream_completed_event() {
478
+ let value = serde_json::json!({
479
+ "type": "image_generation.completed",
480
+ "b64_json": "def456"
481
+ });
482
+
483
+ assert_eq!(
484
+ extract_image_url(&value),
485
+ Some("data:image/png;base64,def456".to_string())
486
+ );
487
+ }
488
+
489
+ #[test]
490
+ fn extracts_stream_error_message() {
491
+ let value = serde_json::json!({
492
+ "type": "response.failed",
493
+ "error": { "message": "image generation failed" }
494
+ });
495
+
496
+ assert_eq!(
497
+ extract_response_error(&value),
498
+ Some("image generation failed".to_string())
499
+ );
500
+ }
501
+
502
+ #[test]
503
+ fn extracts_response_text_from_completed_message() {
504
+ let value = serde_json::json!({
505
+ "type": "response.completed",
506
+ "response": {
507
+ "output": [
508
+ {
509
+ "type": "message",
510
+ "role": "assistant",
511
+ "content": [
512
+ {
513
+ "type": "output_text",
514
+ "text": "I could not generate the image."
515
+ }
516
+ ]
517
+ }
518
+ ]
519
+ }
520
+ });
521
+
522
+ assert_eq!(
523
+ extract_response_text(&value),
524
+ Some("I could not generate the image.".to_string())
525
+ );
526
+ }
527
+
528
+ #[test]
529
+ fn collector_prefers_final_image_and_keeps_text() {
530
+ let mut collector = CodexImageStreamCollector::default();
531
+ collector
532
+ .push(&serde_json::json!({
533
+ "type": "response.output_text.delta",
534
+ "delta": "Working"
535
+ }))
536
+ .unwrap();
537
+ collector
538
+ .push(&serde_json::json!({
539
+ "type": "response.image_generation_call.partial_image",
540
+ "partial_image_b64": "partial"
541
+ }))
542
+ .unwrap();
543
+ collector
544
+ .push(&serde_json::json!({
545
+ "type": "image_generation_call",
546
+ "result": "final"
547
+ }))
548
+ .unwrap();
549
+
550
+ assert_eq!(
551
+ collector.finish(),
552
+ CodexImageStreamResult {
553
+ image_url: Some("data:image/png;base64,final".to_string()),
554
+ response_text: Some("Working".to_string()),
555
+ }
556
+ );
557
+ }
558
  }
koharu-ai/src/codex/mod.rs CHANGED
@@ -16,7 +16,8 @@ pub use device::{DeviceAuthorization, DeviceCode};
16
  pub use error::{CodexError, Result};
17
  pub use image::{
18
  CodexImageGenerationConfig, CodexImageGenerationRequest, CodexImageGenerationTool,
19
- CodexInputImage,
 
20
  };
21
  pub use responses::{CodexInputContent, CodexInputItem};
22
  pub use task::CodexTaskRequest;
 
16
  pub use error::{CodexError, Result};
17
  pub use image::{
18
  CodexImageGenerationConfig, CodexImageGenerationRequest, CodexImageGenerationTool,
19
+ CodexImageStreamResult, CodexInputImage, extract_image_url, image_response_stream_result,
20
+ image_response_stream_url,
21
  };
22
  pub use responses::{CodexInputContent, CodexInputItem};
23
  pub use task::CodexTaskRequest;
koharu-ai/src/lib.rs CHANGED
@@ -1 +1,4 @@
1
  pub mod codex;
 
 
 
 
1
  pub mod codex;
2
+ pub mod provider;
3
+
4
+ pub use provider::{AiImageProvider, AiImageRequest, AiImageResult, AiInputImage};
koharu-ai/src/provider.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use async_trait::async_trait;
2
+
3
+ #[derive(Debug, Clone)]
4
+ pub struct AiInputImage {
5
+ pub data_url: String,
6
+ pub detail: String,
7
+ }
8
+
9
+ #[derive(Debug, Clone)]
10
+ pub struct AiImageRequest {
11
+ pub model: String,
12
+ pub instructions: String,
13
+ pub prompt: String,
14
+ pub input_image: Option<AiInputImage>,
15
+ pub quality: String,
16
+ pub size: Option<String>,
17
+ pub action: Option<String>,
18
+ }
19
+
20
+ #[derive(Debug, Clone, PartialEq, Eq)]
21
+ pub struct AiImageResult {
22
+ pub image_url: String,
23
+ }
24
+
25
+ #[async_trait]
26
+ pub trait AiImageProvider: Send + Sync {
27
+ async fn generate_image(&self, request: AiImageRequest) -> anyhow::Result<AiImageResult>;
28
+ }
29
+
30
+ impl AiImageRequest {
31
+ pub fn new(model: impl Into<String>, prompt: impl Into<String>) -> Self {
32
+ Self {
33
+ model: model.into(),
34
+ instructions: "Generate or edit the requested image.".to_string(),
35
+ prompt: prompt.into(),
36
+ input_image: None,
37
+ quality: "high".to_string(),
38
+ size: None,
39
+ action: None,
40
+ }
41
+ }
42
+
43
+ pub fn with_input_image(mut self, data_url: impl Into<String>) -> Self {
44
+ self.input_image = Some(AiInputImage {
45
+ data_url: data_url.into(),
46
+ detail: "high".to_string(),
47
+ });
48
+ self
49
+ }
50
+ }
koharu-app/Cargo.toml CHANGED
@@ -19,6 +19,7 @@ path = "bin/pipeline.rs"
19
  clap = { workspace = true }
20
  tracing-subscriber = { workspace = true }
21
  koharu-core = { workspace = true }
 
22
  koharu-ml = { workspace = true }
23
  koharu-renderer = { workspace = true }
24
  koharu-llm = { workspace = true }
@@ -27,6 +28,7 @@ anyhow = { workspace = true }
27
  arc-swap = { workspace = true }
28
  async-trait = { workspace = true }
29
  atomicwrites = { workspace = true }
 
30
  blake3 = { workspace = true }
31
  camino = { workspace = true }
32
  chrono = { workspace = true }
 
19
  clap = { workspace = true }
20
  tracing-subscriber = { workspace = true }
21
  koharu-core = { workspace = true }
22
+ koharu-ai = { workspace = true }
23
  koharu-ml = { workspace = true }
24
  koharu-renderer = { workspace = true }
25
  koharu-llm = { workspace = true }
 
28
  arc-swap = { workspace = true }
29
  async-trait = { workspace = true }
30
  atomicwrites = { workspace = true }
31
+ base64 = { workspace = true }
32
  blake3 = { workspace = true }
33
  camino = { workspace = true }
34
  chrono = { workspace = true }
koharu-app/src/ai.rs ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::io::Cursor;
2
+ use std::sync::Arc;
3
+ use std::time::Duration;
4
+
5
+ use anyhow::{Context, Result, anyhow, bail};
6
+ use base64::Engine as _;
7
+ use dashmap::DashMap;
8
+ use image::DynamicImage;
9
+ use koharu_ai::codex::{CodexClient, CodexConfig};
10
+ use koharu_ai::{AiImageProvider, AiImageRequest};
11
+ use koharu_core::{
12
+ BlobRef, ImageData, ImageDataPatch, ImageRole, Node, NodeDataPatch, NodeId, NodeKind,
13
+ NodePatch, Op, PageId, Scene, Transform,
14
+ };
15
+ use koharu_runtime::{RuntimeHttpClient, RuntimeManager};
16
+ use parking_lot::RwLock;
17
+ use serde::{Deserialize, Serialize};
18
+ use tracing::Instrument as _;
19
+ use utoipa::ToSchema;
20
+ use uuid::Uuid;
21
+
22
+ use crate::session::ProjectSession;
23
+
24
+ const DEFAULT_CODEX_IMAGE_MODEL: &str = "gpt-5.5";
25
+ const DEFAULT_CODEX_IMAGE_INSTRUCTIONS: &str = "Generate or edit the requested image.";
26
+ const DEFAULT_CODEX_IMAGE_QUALITY: &str = "high";
27
+
28
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29
+ #[serde(rename_all = "snake_case")]
30
+ pub enum CodexAuthAttemptStatus {
31
+ Pending,
32
+ Succeeded,
33
+ Failed,
34
+ }
35
+
36
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
37
+ #[serde(rename_all = "camelCase")]
38
+ pub struct CodexDeviceLogin {
39
+ pub login_id: String,
40
+ pub verification_url: String,
41
+ pub user_code: String,
42
+ pub interval_seconds: u64,
43
+ pub timeout_seconds: u64,
44
+ }
45
+
46
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
47
+ #[serde(rename_all = "camelCase")]
48
+ pub struct CodexDeviceLoginStatus {
49
+ pub login_id: String,
50
+ pub status: CodexAuthAttemptStatus,
51
+ #[serde(default, skip_serializing_if = "Option::is_none")]
52
+ pub account_id: Option<String>,
53
+ #[serde(default, skip_serializing_if = "Option::is_none")]
54
+ pub error: Option<String>,
55
+ }
56
+
57
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
58
+ #[serde(rename_all = "camelCase")]
59
+ pub struct CodexAuthStatus {
60
+ pub signed_in: bool,
61
+ #[serde(default, skip_serializing_if = "Option::is_none")]
62
+ pub account_id: Option<String>,
63
+ #[serde(default, skip_serializing_if = "Option::is_none")]
64
+ pub login: Option<CodexDeviceLoginStatus>,
65
+ }
66
+
67
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
68
+ #[serde(rename_all = "camelCase")]
69
+ pub struct CodexImageGenerationOptions {
70
+ pub page_id: PageId,
71
+ pub prompt: String,
72
+ #[serde(default, skip_serializing_if = "Option::is_none")]
73
+ pub model: Option<String>,
74
+ #[serde(default, skip_serializing_if = "Option::is_none")]
75
+ pub instructions: Option<String>,
76
+ #[serde(default, skip_serializing_if = "Option::is_none")]
77
+ pub quality: Option<String>,
78
+ #[serde(default, skip_serializing_if = "Option::is_none")]
79
+ pub size: Option<String>,
80
+ }
81
+
82
+ #[derive(Debug, Clone)]
83
+ struct LoginAttempt {
84
+ status: CodexAuthAttemptStatus,
85
+ account_id: Option<String>,
86
+ error: Option<String>,
87
+ }
88
+
89
+ pub struct AiManager {
90
+ codex: CodexClient,
91
+ http_client: RuntimeHttpClient,
92
+ codex_device_timeout: Duration,
93
+ codex_logins: Arc<DashMap<String, LoginAttempt>>,
94
+ latest_codex_login: RwLock<Option<String>>,
95
+ }
96
+
97
+ impl AiManager {
98
+ pub fn new(runtime: &RuntimeManager) -> Self {
99
+ let config = CodexConfig::default();
100
+ let codex_device_timeout = config.device_auth_timeout;
101
+ Self {
102
+ codex: CodexClient::with_http_client(config, runtime.http_client()),
103
+ http_client: runtime.http_client(),
104
+ codex_device_timeout,
105
+ codex_logins: Arc::new(DashMap::new()),
106
+ latest_codex_login: RwLock::new(None),
107
+ }
108
+ }
109
+
110
+ pub fn codex_auth_status(&self) -> Result<CodexAuthStatus> {
111
+ let tokens = self.codex.token_store().load()?;
112
+ let account_id = tokens
113
+ .as_ref()
114
+ .and_then(|tokens| tokens.chatgpt_account_id());
115
+ let login = self
116
+ .latest_codex_login
117
+ .read()
118
+ .as_ref()
119
+ .and_then(|id| {
120
+ self.codex_logins
121
+ .get(id)
122
+ .map(|entry| (id.clone(), entry.clone()))
123
+ })
124
+ .map(|(login_id, attempt)| CodexDeviceLoginStatus {
125
+ login_id,
126
+ status: attempt.status,
127
+ account_id: attempt.account_id,
128
+ error: attempt.error,
129
+ });
130
+
131
+ Ok(CodexAuthStatus {
132
+ signed_in: tokens.is_some(),
133
+ account_id,
134
+ login,
135
+ })
136
+ }
137
+
138
+ pub async fn start_codex_device_login(self: &Arc<Self>) -> Result<CodexDeviceLogin> {
139
+ let device_code = self.codex.request_device_code().await?;
140
+ let login_id = Uuid::new_v4().to_string();
141
+ self.codex_logins.insert(
142
+ login_id.clone(),
143
+ LoginAttempt {
144
+ status: CodexAuthAttemptStatus::Pending,
145
+ account_id: None,
146
+ error: None,
147
+ },
148
+ );
149
+ *self.latest_codex_login.write() = Some(login_id.clone());
150
+
151
+ let manager = Arc::clone(self);
152
+ let device_code_for_task = device_code.clone();
153
+ let login_id_for_task = login_id.clone();
154
+ tokio::spawn(async move {
155
+ let result = manager
156
+ .codex
157
+ .complete_device_code_login(&device_code_for_task)
158
+ .await;
159
+ let attempt = match result {
160
+ Ok(tokens) => LoginAttempt {
161
+ status: CodexAuthAttemptStatus::Succeeded,
162
+ account_id: tokens.chatgpt_account_id(),
163
+ error: None,
164
+ },
165
+ Err(err) => LoginAttempt {
166
+ status: CodexAuthAttemptStatus::Failed,
167
+ account_id: None,
168
+ error: Some(format!("{err:#}")),
169
+ },
170
+ };
171
+ manager.codex_logins.insert(login_id_for_task, attempt);
172
+ });
173
+
174
+ let interval_seconds = device_code.interval().as_secs().max(1);
175
+ Ok(CodexDeviceLogin {
176
+ login_id,
177
+ verification_url: device_code.verification_url,
178
+ user_code: device_code.user_code,
179
+ interval_seconds,
180
+ timeout_seconds: self.codex_device_timeout.as_secs(),
181
+ })
182
+ }
183
+
184
+ pub fn logout_codex(&self) -> Result<()> {
185
+ self.codex.token_store().delete()?;
186
+ Ok(())
187
+ }
188
+
189
+ pub async fn generate_codex_page_image(
190
+ &self,
191
+ session: Arc<ProjectSession>,
192
+ options: CodexImageGenerationOptions,
193
+ cancel: Arc<std::sync::atomic::AtomicBool>,
194
+ ) -> Result<()> {
195
+ let workflow_span = tracing::info_span!(
196
+ "codex_image_generation_workflow",
197
+ page_id = %options.page_id
198
+ );
199
+ async move {
200
+ let prompt = options.prompt.trim().to_string();
201
+ if prompt.is_empty() {
202
+ bail!("prompt is required");
203
+ }
204
+
205
+ let source = tracing::info_span!("codex_source_image_load").in_scope(|| {
206
+ let scene = session.scene_snapshot();
207
+ let (_, image_data) = source_image(&scene, options.page_id)?;
208
+ session.blobs.load_image(&image_data.blob)
209
+ })?;
210
+
211
+ let source_data_url = tracing::info_span!("codex_source_image_encode")
212
+ .in_scope(|| image_data_url(&source))?;
213
+ tracing::info!(bytes = source_data_url.len(), "encoded Codex source image");
214
+
215
+ check_cancelled(&cancel)?;
216
+ let mut request = AiImageRequest::new(
217
+ options
218
+ .model
219
+ .filter(|value| !value.trim().is_empty())
220
+ .unwrap_or_else(|| DEFAULT_CODEX_IMAGE_MODEL.to_string()),
221
+ prompt,
222
+ )
223
+ .with_input_image(source_data_url);
224
+ request.instructions = options
225
+ .instructions
226
+ .filter(|value| !value.trim().is_empty())
227
+ .unwrap_or_else(|| DEFAULT_CODEX_IMAGE_INSTRUCTIONS.to_string());
228
+ request.quality = options
229
+ .quality
230
+ .filter(|value| !value.trim().is_empty())
231
+ .unwrap_or_else(|| DEFAULT_CODEX_IMAGE_QUALITY.to_string());
232
+ request.size = options
233
+ .size
234
+ .filter(|value| !value.trim().is_empty())
235
+ .or_else(|| Some("auto".to_string()));
236
+ request.action = Some("edit".to_string());
237
+
238
+ let result = self
239
+ .codex
240
+ .generate_image(request)
241
+ .instrument(tracing::info_span!("codex_image_request"))
242
+ .await?;
243
+ tracing::info!("Codex image request completed");
244
+
245
+ check_cancelled(&cancel)?;
246
+ let generated_bytes = self
247
+ .load_generated_image_bytes(&result.image_url)
248
+ .instrument(tracing::info_span!("codex_generated_image_load"))
249
+ .await?;
250
+ tracing::info!(
251
+ bytes = generated_bytes.len(),
252
+ "loaded Codex generated image bytes"
253
+ );
254
+
255
+ let (width, height, blob) = tracing::info_span!("codex_generated_image_store")
256
+ .in_scope(|| {
257
+ let generated = image::load_from_memory(&generated_bytes)
258
+ .with_context(|| "failed to decode Codex image result")?;
259
+ let (width, height) = image_dimensions(&generated);
260
+ let blob = session.blobs.put_webp(&generated)?;
261
+ Ok::<_, anyhow::Error>((width, height, blob))
262
+ })?;
263
+ tracing::info!(width, height, "decoded and stored Codex generated image");
264
+
265
+ check_cancelled(&cancel)?;
266
+ let scene = session.scene_snapshot();
267
+ let op = upsert_image_blob(
268
+ &scene,
269
+ options.page_id,
270
+ ImageRole::Rendered,
271
+ blob,
272
+ width,
273
+ height,
274
+ )?;
275
+ session.apply(Op::Batch {
276
+ ops: vec![op],
277
+ label: format!("codex-image: page {}", options.page_id),
278
+ })?;
279
+ tracing::info!("finished Codex image generation workflow");
280
+ Ok(())
281
+ }
282
+ .instrument(workflow_span)
283
+ .await
284
+ }
285
+
286
+ async fn load_generated_image_bytes(&self, url: &str) -> Result<Vec<u8>> {
287
+ if let Some(bytes) = decode_data_image_url(url)? {
288
+ return Ok(bytes);
289
+ }
290
+
291
+ if !(url.starts_with("http://") || url.starts_with("https://")) {
292
+ bail!("unsupported Codex image result URL: {url}");
293
+ }
294
+
295
+ let response = self.http_client.get(url).send().await?;
296
+ let status = response.status();
297
+ if !status.is_success() {
298
+ let body = response.text().await.unwrap_or_default();
299
+ bail!("failed to fetch Codex image result ({status}): {body}");
300
+ }
301
+ Ok(response.bytes().await?.to_vec())
302
+ }
303
+ }
304
+
305
+ fn check_cancelled(cancel: &std::sync::atomic::AtomicBool) -> Result<()> {
306
+ if cancel.load(std::sync::atomic::Ordering::Relaxed) {
307
+ bail!("cancelled");
308
+ }
309
+ Ok(())
310
+ }
311
+
312
+ fn source_image(scene: &Scene, page_id: PageId) -> Result<(NodeId, &ImageData)> {
313
+ let page = scene
314
+ .page(page_id)
315
+ .with_context(|| format!("page {} not found", page_id))?;
316
+ page.nodes
317
+ .iter()
318
+ .find_map(|(id, node)| match &node.kind {
319
+ NodeKind::Image(image) if image.role == ImageRole::Source => Some((*id, image)),
320
+ _ => None,
321
+ })
322
+ .ok_or_else(|| anyhow!("page has no Source image node"))
323
+ }
324
+
325
+ fn image_data_url(image: &DynamicImage) -> Result<String> {
326
+ let mut buf = Cursor::new(Vec::new());
327
+ image.write_to(&mut buf, image::ImageFormat::Png)?;
328
+ let encoded = base64::engine::general_purpose::STANDARD.encode(buf.into_inner());
329
+ Ok(format!("data:image/png;base64,{encoded}"))
330
+ }
331
+
332
+ fn decode_data_image_url(url: &str) -> Result<Option<Vec<u8>>> {
333
+ let Some(rest) = url.strip_prefix("data:image/") else {
334
+ return Ok(None);
335
+ };
336
+ let Some((_, data)) = rest.split_once(',') else {
337
+ bail!("invalid data image URL");
338
+ };
339
+ let decoded = base64::engine::general_purpose::STANDARD
340
+ .decode(data)
341
+ .context("failed to decode data image URL")?;
342
+ Ok(Some(decoded))
343
+ }
344
+
345
+ fn image_dimensions(image: &DynamicImage) -> (u32, u32) {
346
+ use image::GenericImageView as _;
347
+ image.dimensions()
348
+ }
349
+
350
+ fn upsert_image_blob(
351
+ scene: &Scene,
352
+ page: PageId,
353
+ role: ImageRole,
354
+ blob: BlobRef,
355
+ natural_width: u32,
356
+ natural_height: u32,
357
+ ) -> Result<Op> {
358
+ let page_ref = scene
359
+ .page(page)
360
+ .with_context(|| format!("page {} not found", page))?;
361
+
362
+ if let Some((node_id, _)) = page_ref
363
+ .nodes
364
+ .iter()
365
+ .find_map(|(id, node)| match &node.kind {
366
+ NodeKind::Image(image) if image.role == role => Some((*id, image)),
367
+ _ => None,
368
+ })
369
+ {
370
+ return Ok(Op::UpdateNode {
371
+ page,
372
+ id: node_id,
373
+ patch: NodePatch {
374
+ data: Some(NodeDataPatch::Image(ImageDataPatch {
375
+ blob: Some(blob),
376
+ opacity: None,
377
+ name: None,
378
+ natural_width: Some(natural_width),
379
+ natural_height: Some(natural_height),
380
+ })),
381
+ transform: None,
382
+ visible: None,
383
+ },
384
+ prev: NodePatch::default(),
385
+ });
386
+ }
387
+
388
+ let at = if role == ImageRole::Inpainted {
389
+ 1.min(page_ref.nodes.len())
390
+ } else {
391
+ page_ref.nodes.len()
392
+ };
393
+ Ok(Op::AddNode {
394
+ page,
395
+ node: Node {
396
+ id: NodeId::new(),
397
+ transform: Transform::default(),
398
+ visible: role != ImageRole::Rendered,
399
+ kind: NodeKind::Image(ImageData {
400
+ role,
401
+ blob,
402
+ opacity: 1.0,
403
+ natural_width,
404
+ natural_height,
405
+ name: None,
406
+ }),
407
+ },
408
+ at,
409
+ })
410
+ }
411
+
412
+ #[cfg(test)]
413
+ mod tests {
414
+ use super::*;
415
+
416
+ #[test]
417
+ fn decodes_data_image_url() {
418
+ let encoded = base64::engine::general_purpose::STANDARD.encode(b"png");
419
+ let decoded = decode_data_image_url(&format!("data:image/png;base64,{encoded}")).unwrap();
420
+ assert_eq!(decoded, Some(b"png".to_vec()));
421
+ }
422
+
423
+ #[test]
424
+ fn ignores_non_data_url() {
425
+ assert!(
426
+ decode_data_image_url("https://example.test/image.png")
427
+ .unwrap()
428
+ .is_none()
429
+ );
430
+ }
431
+ }
koharu-app/src/app.rs CHANGED
@@ -23,6 +23,7 @@ use koharu_core::{AppEvent, DownloadProgress, JobSummary, LlmStateStatus};
23
  use koharu_runtime::{ComputePolicy, RuntimeManager};
24
  use tokio::sync::Mutex;
25
 
 
26
  use crate::autosave::{self, AutosaveSignal};
27
  use crate::bus::EventBus;
28
  use crate::config::AppConfig;
@@ -62,6 +63,7 @@ pub struct App {
62
  pub jobs: Arc<DashMap<String, JobSummary>>,
63
  pub downloads: Arc<DashMap<String, DownloadProgress>>,
64
  pub bus: Arc<EventBus>,
 
65
  pub llm: Arc<llm::Model>,
66
  pub renderer: Arc<renderer::Renderer>,
67
  /// Autosave handle (tx + join) for the currently-open session. `None` = no project open.
@@ -92,6 +94,7 @@ impl App {
92
  ) -> Result<Self> {
93
  let backend = shared_llama_backend(&runtime)?;
94
  let llm = Arc::new(llm::Model::new((*runtime).clone(), cpu, backend));
 
95
  let renderer = Arc::new(renderer::Renderer::new()?);
96
  Ok(Self {
97
  config: Arc::new(ArcSwap::from_pointee(config)),
@@ -101,6 +104,7 @@ impl App {
101
  jobs: shared.jobs,
102
  downloads: shared.downloads,
103
  bus: shared.bus,
 
104
  llm,
105
  renderer,
106
  autosave: Mutex::new(None),
 
23
  use koharu_runtime::{ComputePolicy, RuntimeManager};
24
  use tokio::sync::Mutex;
25
 
26
+ use crate::ai::AiManager;
27
  use crate::autosave::{self, AutosaveSignal};
28
  use crate::bus::EventBus;
29
  use crate::config::AppConfig;
 
63
  pub jobs: Arc<DashMap<String, JobSummary>>,
64
  pub downloads: Arc<DashMap<String, DownloadProgress>>,
65
  pub bus: Arc<EventBus>,
66
+ pub ai: Arc<AiManager>,
67
  pub llm: Arc<llm::Model>,
68
  pub renderer: Arc<renderer::Renderer>,
69
  /// Autosave handle (tx + join) for the currently-open session. `None` = no project open.
 
94
  ) -> Result<Self> {
95
  let backend = shared_llama_backend(&runtime)?;
96
  let llm = Arc::new(llm::Model::new((*runtime).clone(), cpu, backend));
97
+ let ai = Arc::new(AiManager::new(&runtime));
98
  let renderer = Arc::new(renderer::Renderer::new()?);
99
  Ok(Self {
100
  config: Arc::new(ArcSwap::from_pointee(config)),
 
104
  jobs: shared.jobs,
105
  downloads: shared.downloads,
106
  bus: shared.bus,
107
+ ai,
108
  llm,
109
  renderer,
110
  autosave: Mutex::new(None),
koharu-app/src/lib.rs CHANGED
@@ -3,6 +3,7 @@
3
  //!
4
  //! See [`crate::app::App`] for the entry point.
5
 
 
6
  pub mod app;
7
  pub mod archive;
8
  pub mod autosave;
@@ -18,6 +19,7 @@ pub mod renderer;
18
  pub mod session;
19
  pub mod utils;
20
 
 
21
  pub use app::{App, AppSharedState};
22
  pub use blobs::BlobStore;
23
  pub use config::AppConfig;
 
3
  //!
4
  //! See [`crate::app::App`] for the entry point.
5
 
6
+ pub mod ai;
7
  pub mod app;
8
  pub mod archive;
9
  pub mod autosave;
 
19
  pub mod session;
20
  pub mod utils;
21
 
22
+ pub use ai::AiManager;
23
  pub use app::{App, AppSharedState};
24
  pub use blobs::BlobStore;
25
  pub use config::AppConfig;
koharu-rpc/src/api.rs CHANGED
@@ -37,6 +37,7 @@ fn app_api() -> OpenApiRouter<ApiState> {
37
  .merge(routes::meta::router())
38
  .merge(routes::fonts::router())
39
  .merge(routes::llm::router())
 
40
  .merge(routes::pipelines::router())
41
  .merge(binary::router())
42
  }
 
37
  .merge(routes::meta::router())
38
  .merge(routes::fonts::router())
39
  .merge(routes::llm::router())
40
+ .merge(routes::ai::router())
41
  .merge(routes::pipelines::router())
42
  .merge(binary::router())
43
  }
koharu-rpc/src/routes/ai.rs ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! AI workflow routes. These are separate from `/llm/*` because Codex image
2
+ //! generation is not a translation model lifecycle concern.
3
+
4
+ use std::sync::Arc;
5
+ use std::sync::atomic::AtomicBool;
6
+
7
+ use axum::Json;
8
+ use axum::extract::State;
9
+ use axum::http::StatusCode;
10
+ use koharu_app::ai::{CodexAuthStatus, CodexDeviceLogin, CodexImageGenerationOptions};
11
+ use koharu_core::{AppEvent, JobFinishedEvent, JobStatus, JobSummary};
12
+ use serde::{Deserialize, Serialize};
13
+ use utoipa_axum::{router::OpenApiRouter, routes};
14
+ use uuid::Uuid;
15
+
16
+ use crate::AppState;
17
+ use crate::error::{ApiError, ApiResult};
18
+ use crate::routes::operations::{register_cancel, unregister_cancel};
19
+
20
+ pub fn router() -> OpenApiRouter<AppState> {
21
+ OpenApiRouter::default()
22
+ .routes(routes!(get_codex_auth_status))
23
+ .routes(routes!(start_codex_device_login))
24
+ .routes(routes!(delete_codex_session))
25
+ .routes(routes!(start_codex_image_generation))
26
+ }
27
+
28
+ #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
29
+ #[serde(rename_all = "camelCase")]
30
+ pub struct CodexImageGenerationResponse {
31
+ pub operation_id: String,
32
+ }
33
+
34
+ #[utoipa::path(
35
+ get,
36
+ path = "/ai/codex/auth/status",
37
+ responses((status = 200, body = CodexAuthStatus))
38
+ )]
39
+ async fn get_codex_auth_status(State(app): State<AppState>) -> ApiResult<Json<CodexAuthStatus>> {
40
+ app.ai
41
+ .codex_auth_status()
42
+ .map(Json)
43
+ .map_err(ApiError::internal)
44
+ }
45
+
46
+ #[utoipa::path(
47
+ post,
48
+ path = "/ai/codex/auth/device-code",
49
+ responses((status = 200, body = CodexDeviceLogin))
50
+ )]
51
+ async fn start_codex_device_login(
52
+ State(app): State<AppState>,
53
+ ) -> ApiResult<Json<CodexDeviceLogin>> {
54
+ app.ai
55
+ .start_codex_device_login()
56
+ .await
57
+ .map(Json)
58
+ .map_err(ApiError::internal)
59
+ }
60
+
61
+ #[utoipa::path(delete, path = "/ai/codex/auth/session", responses((status = 204)))]
62
+ async fn delete_codex_session(State(app): State<AppState>) -> ApiResult<StatusCode> {
63
+ app.ai.logout_codex().map_err(ApiError::internal)?;
64
+ Ok(StatusCode::NO_CONTENT)
65
+ }
66
+
67
+ #[utoipa::path(
68
+ post,
69
+ path = "/ai/codex/images",
70
+ request_body = CodexImageGenerationOptions,
71
+ responses((status = 200, body = CodexImageGenerationResponse))
72
+ )]
73
+ async fn start_codex_image_generation(
74
+ State(app): State<AppState>,
75
+ Json(req): Json<CodexImageGenerationOptions>,
76
+ ) -> ApiResult<Json<CodexImageGenerationResponse>> {
77
+ let session = app
78
+ .current_session()
79
+ .ok_or_else(|| ApiError::bad_request("no project open"))?;
80
+
81
+ let operation_id = Uuid::new_v4().to_string();
82
+ let cancel = Arc::new(AtomicBool::new(false));
83
+ register_cancel(operation_id.clone(), cancel.clone());
84
+
85
+ app.jobs.insert(
86
+ operation_id.clone(),
87
+ JobSummary {
88
+ id: operation_id.clone(),
89
+ kind: "ai".to_string(),
90
+ status: JobStatus::Running,
91
+ error: None,
92
+ },
93
+ );
94
+ app.bus.publish(AppEvent::JobStarted {
95
+ id: operation_id.clone(),
96
+ kind: "ai".to_string(),
97
+ });
98
+
99
+ let app_c = app.clone();
100
+ let session_c = session.clone();
101
+ let op_id_c = operation_id.clone();
102
+ tokio::spawn(async move {
103
+ let result = app_c
104
+ .ai
105
+ .generate_codex_page_image(session_c, req, cancel)
106
+ .await;
107
+ let (status, error) = match result {
108
+ Ok(()) => (JobStatus::Completed, None),
109
+ Err(e) if e.to_string().contains("cancelled") => (JobStatus::Cancelled, None),
110
+ Err(e) => {
111
+ tracing::warn!(operation_id = %op_id_c, "Codex image generation failed: {e:#}");
112
+ (JobStatus::Failed, Some(format!("{e:#}")))
113
+ }
114
+ };
115
+ app_c.jobs.insert(
116
+ op_id_c.clone(),
117
+ JobSummary {
118
+ id: op_id_c.clone(),
119
+ kind: "ai".to_string(),
120
+ status,
121
+ error: error.clone(),
122
+ },
123
+ );
124
+ app_c.bus.publish(AppEvent::JobFinished(JobFinishedEvent {
125
+ id: op_id_c.clone(),
126
+ status,
127
+ error,
128
+ }));
129
+ unregister_cancel(&op_id_c);
130
+ });
131
+
132
+ Ok(Json(CodexImageGenerationResponse { operation_id }))
133
+ }
koharu-rpc/src/routes/mod.rs CHANGED
@@ -2,6 +2,7 @@
2
  //! `OpenApiRouter<ApiState>` that can be merged into the top-level router in
3
  //! `api.rs`.
4
 
 
5
  pub mod config;
6
  pub mod downloads;
7
  pub mod fonts;
 
2
  //! `OpenApiRouter<ApiState>` that can be merged into the top-level router in
3
  //! `api.rs`.
4
 
5
+ pub mod ai;
6
  pub mod config;
7
  pub mod downloads;
8
  pub mod fonts;
koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap CHANGED
@@ -4,6 +4,30 @@ assertion_line: 34
4
  expression: paths
5
  ---
6
  [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  (
8
  "/blobs/{hash}",
9
  [
 
4
  expression: paths
5
  ---
6
  [
7
+ (
8
+ "/ai/codex/auth/device-code",
9
+ [
10
+ "post",
11
+ ],
12
+ ),
13
+ (
14
+ "/ai/codex/auth/session",
15
+ [
16
+ "delete",
17
+ ],
18
+ ),
19
+ (
20
+ "/ai/codex/auth/status",
21
+ [
22
+ "get",
23
+ ],
24
+ ),
25
+ (
26
+ "/ai/codex/images",
27
+ [
28
+ "post",
29
+ ],
30
+ ),
31
  (
32
  "/blobs/{hash}",
33
  [
ui/components/Panels.tsx CHANGED
@@ -1,16 +1,26 @@
1
  'use client'
2
 
3
- import { LayersIcon, SlidersHorizontalIcon } from 'lucide-react'
 
4
  import { useTranslation } from 'react-i18next'
5
 
 
6
  import { LayersPanel } from '@/components/panels/LayersPanel'
7
  import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
8
  import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
9
  import { ScrollArea } from '@/components/ui/scroll-area'
10
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 
11
 
12
  export function Panels() {
13
  const { t } = useTranslation()
 
 
 
 
 
 
 
14
 
15
  return (
16
  <div className='flex h-full min-h-0 w-full flex-col border-l bg-muted/50'>
@@ -57,8 +67,51 @@ export function Panels() {
57
  </TabsContent>
58
  </Tabs>
59
 
60
- {/* Text Blocks Section - takes remaining space */}
61
- <TextBlocksPanel />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  </div>
63
  )
64
  }
 
1
  'use client'
2
 
3
+ import { LayersIcon, SlidersHorizontalIcon, SparklesIcon, TypeIcon } from 'lucide-react'
4
+ import { useEffect, useState } from 'react'
5
  import { useTranslation } from 'react-i18next'
6
 
7
+ import { AiPanel } from '@/components/panels/AiPanel'
8
  import { LayersPanel } from '@/components/panels/LayersPanel'
9
  import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
10
  import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
11
  import { ScrollArea } from '@/components/ui/scroll-area'
12
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
13
+ import { useGetCodexAuthStatus } from '@/lib/api/default/default'
14
 
15
  export function Panels() {
16
  const { t } = useTranslation()
17
+ const { data: codexAuth } = useGetCodexAuthStatus()
18
+ const codexSignedIn = codexAuth?.signedIn === true
19
+ const [workTab, setWorkTab] = useState('text')
20
+
21
+ useEffect(() => {
22
+ if (!codexSignedIn && workTab === 'ai') setWorkTab('text')
23
+ }, [codexSignedIn, workTab])
24
 
25
  return (
26
  <div className='flex h-full min-h-0 w-full flex-col border-l bg-muted/50'>
 
67
  </TabsContent>
68
  </Tabs>
69
 
70
+ <Tabs
71
+ value={workTab}
72
+ onValueChange={setWorkTab}
73
+ className='min-h-0 flex-1 gap-0'
74
+ data-testid='panels-work-tabs'
75
+ >
76
+ {codexSignedIn && (
77
+ <TabsList className='m-2 mb-0 grid w-[calc(100%-1rem)] grid-cols-2 bg-muted/70'>
78
+ <TabsTrigger value='text' data-testid='panels-tab-textblocks' className='gap-1'>
79
+ <TypeIcon className='size-3.5' />
80
+ <span className='text-xs font-semibold tracking-wide uppercase'>
81
+ {t('layers.textBlocks')}
82
+ </span>
83
+ </TabsTrigger>
84
+ <TabsTrigger value='ai' data-testid='panels-tab-ai' className='gap-1'>
85
+ <SparklesIcon className='size-3.5' />
86
+ <span className='text-xs font-semibold tracking-wide uppercase'>
87
+ {t('panels.ai')}
88
+ </span>
89
+ </TabsTrigger>
90
+ </TabsList>
91
+ )}
92
+
93
+ <TabsContent
94
+ value='text'
95
+ className='flex min-h-0 flex-1 data-[state=inactive]:hidden'
96
+ data-testid='panels-textblocks-tab'
97
+ >
98
+ <TextBlocksPanel />
99
+ </TabsContent>
100
+
101
+ {codexSignedIn && (
102
+ <TabsContent
103
+ value='ai'
104
+ className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
105
+ data-testid='panels-ai'
106
+ >
107
+ <ScrollArea className='h-full' viewportClassName='pr-1 [&>div]:!block'>
108
+ <div className='pt-2'>
109
+ <AiPanel />
110
+ </div>
111
+ </ScrollArea>
112
+ </TabsContent>
113
+ )}
114
+ </Tabs>
115
  </div>
116
  )
117
  }
ui/components/SettingsDialog.tsx CHANGED
@@ -17,6 +17,11 @@ import {
17
  SaveIcon,
18
  RotateCcwIcon,
19
  AlertTriangleIcon,
 
 
 
 
 
20
  } from 'lucide-react'
21
  import { useTheme } from 'next-themes'
22
  import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
@@ -57,10 +62,15 @@ import {
57
  getGetCatalogQueryKey as getGetLlmCatalogQueryKey,
58
  getMeta,
59
  patchConfig,
 
 
 
 
60
  } from '@/lib/api/default/default'
61
  import type {
62
  AppConfig,
63
  ConfigPatch,
 
64
  EngineCatalog as GetEngineCatalog200,
65
  LlmProviderCatalog,
66
  ProviderConfig,
@@ -127,6 +137,7 @@ const TABS = [
127
  { id: 'appearance', icon: PaletteIcon, labelKey: 'settings.appearance' },
128
  { id: 'engines', icon: CpuIcon, labelKey: 'settings.engines' },
129
  { id: 'providers', icon: KeyIcon, labelKey: 'settings.apiKeys' },
 
130
  { id: 'keybinds', icon: KeyboardIcon, labelKey: 'settings.keybinds' },
131
  { id: 'runtime', icon: HardDriveIcon, labelKey: 'settings.runtime' },
132
  { id: 'about', icon: InfoIcon, labelKey: 'settings.about' },
@@ -386,6 +397,7 @@ export function SettingsDialog({
386
  }}
387
  />
388
  )}
 
389
  {tab === 'runtime' && (
390
  <StoragePane
391
  dataPath={dataPathDraft}
@@ -676,6 +688,187 @@ function ProvidersPane({
676
 
677
  // โ”€โ”€ Keybinds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  const SHORTCUT_ITEMS = [
680
  { key: 'select', labelKey: 'toolRail.select' },
681
  { key: 'block', labelKey: 'toolRail.block' },
 
17
  SaveIcon,
18
  RotateCcwIcon,
19
  AlertTriangleIcon,
20
+ CopyIcon,
21
+ ExternalLinkIcon,
22
+ LogInIcon,
23
+ LogOutIcon,
24
+ SparklesIcon,
25
  } from 'lucide-react'
26
  import { useTheme } from 'next-themes'
27
  import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
 
62
  getGetCatalogQueryKey as getGetLlmCatalogQueryKey,
63
  getMeta,
64
  patchConfig,
65
+ deleteCodexSession,
66
+ getGetCodexAuthStatusQueryKey,
67
+ startCodexDeviceLogin,
68
+ useGetCodexAuthStatus,
69
  } from '@/lib/api/default/default'
70
  import type {
71
  AppConfig,
72
  ConfigPatch,
73
+ CodexDeviceLogin,
74
  EngineCatalog as GetEngineCatalog200,
75
  LlmProviderCatalog,
76
  ProviderConfig,
 
137
  { id: 'appearance', icon: PaletteIcon, labelKey: 'settings.appearance' },
138
  { id: 'engines', icon: CpuIcon, labelKey: 'settings.engines' },
139
  { id: 'providers', icon: KeyIcon, labelKey: 'settings.apiKeys' },
140
+ { id: 'ai', icon: SparklesIcon, labelKey: 'settings.ai' },
141
  { id: 'keybinds', icon: KeyboardIcon, labelKey: 'settings.keybinds' },
142
  { id: 'runtime', icon: HardDriveIcon, labelKey: 'settings.runtime' },
143
  { id: 'about', icon: InfoIcon, labelKey: 'settings.about' },
 
397
  }}
398
  />
399
  )}
400
+ {tab === 'ai' && <CodexSettingsPane />}
401
  {tab === 'runtime' && (
402
  <StoragePane
403
  dataPath={dataPathDraft}
 
688
 
689
  // โ”€โ”€ Keybinds โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
690
 
691
+ function CodexSettingsPane() {
692
+ const { t } = useTranslation()
693
+ const queryClient = useQueryClient()
694
+ const [login, setLogin] = useState<CodexDeviceLogin | null>(null)
695
+ const [loginOpen, setLoginOpen] = useState(false)
696
+ const [busy, setBusy] = useState(false)
697
+ const [copied, setCopied] = useState(false)
698
+ const [actionError, setActionError] = useState<string | null>(null)
699
+ const { data: auth, refetch } = useGetCodexAuthStatus()
700
+
701
+ const loginStatus = auth?.login?.status
702
+ const signedIn = auth?.signedIn === true
703
+
704
+ useEffect(() => {
705
+ if (!loginOpen && loginStatus !== 'pending') return
706
+ const id = window.setInterval(() => void refetch(), 2000)
707
+ return () => window.clearInterval(id)
708
+ }, [loginOpen, loginStatus, refetch])
709
+
710
+ useEffect(() => {
711
+ if (loginOpen && (signedIn || loginStatus === 'succeeded')) {
712
+ const id = window.setTimeout(() => setLoginOpen(false), 700)
713
+ return () => window.clearTimeout(id)
714
+ }
715
+ }, [loginOpen, loginStatus, signedIn])
716
+
717
+ const statusLabel = useMemo(() => {
718
+ if (signedIn) return auth?.accountId ? auth.accountId : t('ai.signedIn')
719
+ if (loginStatus === 'failed') return t('ai.signInFailed')
720
+ if (loginStatus === 'pending') return t('ai.signInPending')
721
+ return t('ai.signedOut')
722
+ }, [auth?.accountId, loginStatus, signedIn, t])
723
+
724
+ const invalidateAuth = () =>
725
+ queryClient.invalidateQueries({ queryKey: getGetCodexAuthStatusQueryKey() })
726
+
727
+ const handleSignIn = async () => {
728
+ setBusy(true)
729
+ setActionError(null)
730
+ try {
731
+ const next = await startCodexDeviceLogin()
732
+ setLogin(next)
733
+ setCopied(false)
734
+ setLoginOpen(true)
735
+ void invalidateAuth()
736
+ void openExternalUrl(next.verificationUrl)
737
+ } catch (err) {
738
+ setActionError(String(err))
739
+ } finally {
740
+ setBusy(false)
741
+ }
742
+ }
743
+
744
+ const handleLogout = async () => {
745
+ setBusy(true)
746
+ setActionError(null)
747
+ try {
748
+ await deleteCodexSession()
749
+ await invalidateAuth()
750
+ } catch (err) {
751
+ setActionError(String(err))
752
+ } finally {
753
+ setBusy(false)
754
+ }
755
+ }
756
+
757
+ const handleCopyCode = async () => {
758
+ if (!login?.userCode || typeof navigator === 'undefined') return
759
+ await navigator.clipboard?.writeText(login.userCode)
760
+ setCopied(true)
761
+ window.setTimeout(() => setCopied(false), 1200)
762
+ }
763
+
764
+ return (
765
+ <Section title={t('settings.codex')} description={t('settings.codexDescription')}>
766
+ <div className='rounded-md border border-amber-200/70 bg-amber-50/80 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'>
767
+ {t('settings.codexTwoFactorDescription')}
768
+ </div>
769
+ <div className='rounded-md border border-border bg-card p-3'>
770
+ <div className='flex items-center justify-between gap-3'>
771
+ <div className='flex min-w-0 items-center gap-2'>
772
+ <div className='flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary'>
773
+ <SparklesIcon className='size-4' />
774
+ </div>
775
+ <div className='min-w-0'>
776
+ <div className='text-sm font-medium text-foreground'>Codex</div>
777
+ <div className='truncate text-xs text-muted-foreground'>{statusLabel}</div>
778
+ </div>
779
+ </div>
780
+ {signedIn ? (
781
+ <Button
782
+ variant='outline'
783
+ size='sm'
784
+ className='gap-1.5'
785
+ disabled={busy}
786
+ onClick={() => void handleLogout()}
787
+ >
788
+ <LogOutIcon className='size-3.5' />
789
+ {t('ai.signOut')}
790
+ </Button>
791
+ ) : (
792
+ <Button
793
+ variant='default'
794
+ size='sm'
795
+ className='gap-1.5'
796
+ disabled={busy}
797
+ onClick={() => void handleSignIn()}
798
+ >
799
+ {busy ? (
800
+ <LoaderIcon className='size-3.5 animate-spin' />
801
+ ) : (
802
+ <LogInIcon className='size-3.5' />
803
+ )}
804
+ {t('ai.signIn')}
805
+ </Button>
806
+ )}
807
+ </div>
808
+ {(actionError || (auth?.login?.status === 'failed' && auth.login.error)) && (
809
+ <p className='mt-2 line-clamp-3 text-xs text-destructive'>
810
+ {actionError || auth?.login?.error}
811
+ </p>
812
+ )}
813
+ </div>
814
+
815
+ <Dialog open={loginOpen} onOpenChange={setLoginOpen}>
816
+ <DialogContent className='w-[340px] max-w-[92vw] gap-3 p-4'>
817
+ <DialogTitle className='text-sm'>{t('ai.signInTitle')}</DialogTitle>
818
+ <DialogDescription className='sr-only'>{t('ai.signIn')}</DialogDescription>
819
+ <div className='flex items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2'>
820
+ <div className='min-w-0'>
821
+ <div className='text-[10px] font-semibold tracking-wide text-muted-foreground uppercase'>
822
+ {t('ai.userCode')}
823
+ </div>
824
+ <div className='mt-0.5 font-mono text-xl font-semibold tracking-widest'>
825
+ {login?.userCode ?? '...'}
826
+ </div>
827
+ </div>
828
+ <Button
829
+ variant='outline'
830
+ size='icon-sm'
831
+ disabled={!login}
832
+ aria-label={copied ? t('common.copied') : t('common.copy')}
833
+ onClick={() => void handleCopyCode()}
834
+ >
835
+ <CopyIcon className='size-3.5' />
836
+ </Button>
837
+ </div>
838
+ <Button
839
+ variant='outline'
840
+ size='sm'
841
+ className='w-full gap-1.5'
842
+ disabled={!login}
843
+ onClick={() => login && void openExternalUrl(login.verificationUrl)}
844
+ >
845
+ <ExternalLinkIcon className='size-3.5' />
846
+ {t('ai.openBrowser')}
847
+ </Button>
848
+ <div className='flex items-center gap-2 text-xs text-muted-foreground'>
849
+ {signedIn || loginStatus === 'succeeded' ? (
850
+ <>
851
+ <CheckCircleIcon className='size-4 text-green-500' />
852
+ {t('ai.signInComplete')}
853
+ </>
854
+ ) : loginStatus === 'failed' ? (
855
+ <>
856
+ <AlertCircleIcon className='size-4 text-destructive' />
857
+ <span className='line-clamp-2'>{auth?.login?.error ?? t('ai.signInFailed')}</span>
858
+ </>
859
+ ) : (
860
+ <>
861
+ <LoaderIcon className='size-4 animate-spin' />
862
+ {t('ai.signInPending')}
863
+ </>
864
+ )}
865
+ </div>
866
+ </DialogContent>
867
+ </Dialog>
868
+ </Section>
869
+ )
870
+ }
871
+
872
  const SHORTCUT_ITEMS = [
873
  { key: 'select', labelKey: 'toolRail.select' },
874
  { key: 'block', labelKey: 'toolRail.block' },
ui/components/canvas/SubToolRail.tsx CHANGED
@@ -30,84 +30,84 @@ export function SubToolRail() {
30
  <AnimatePresence>
31
  {isBrushTool && (
32
  <motion.div
33
- initial={{ x: -20, opacity: 0 }}
34
- animate={{ x: 0, opacity: 1 }}
35
- exit={{ x: -20, opacity: 0 }}
36
- transition={{ duration: 0.2, ease: 'easeOut' }}
37
- className='absolute top-14 left-11 z-50 ml-1 flex w-[260px] flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl'
38
- data-testid='sub-tool-rail'
39
- >
40
- <div className='space-y-4 p-4'>
41
- {/* Brush Size */}
42
- <div className='space-y-2'>
43
- <p id='brush-size-label' className='text-[11px] font-medium text-muted-foreground'>
44
- {t('toolbar.brushSize')}
45
- </p>
46
- <div className='flex items-center gap-2'>
47
- <Slider
48
- min={8}
49
- max={128}
50
- step={4}
51
- value={[localSize]}
52
- onValueChange={(vals) => setLocalSize(vals[0] ?? localSize)}
53
- onValueCommit={(vals) => setBrushConfig({ size: vals[0] ?? localSize })}
54
- className='flex-1'
55
- aria-labelledby='brush-size-label'
56
- />
57
- <div className='flex shrink-0 items-center gap-1.5'>
58
- <Input
59
- value={localSize}
60
- readOnly
61
- aria-label='Brush size value'
62
- className='h-8 w-11 border-border/50 bg-muted/20 px-1 text-center text-[11px]'
63
  />
64
- <span
65
- className='w-4 text-[10px] font-medium text-muted-foreground'
66
- aria-hidden='true'
67
- >
68
- px
69
- </span>
 
 
 
 
 
 
 
 
70
  </div>
71
  </div>
72
- </div>
73
 
74
- {/* Color Picker Section */}
75
- <AnimatePresence initial={false}>
76
- {mode === 'brush' && (
77
- <motion.div
78
- initial={{ height: 0, opacity: 0 }}
79
- animate={{ height: 'auto', opacity: 1 }}
80
- exit={{ height: 0, opacity: 0 }}
81
- transition={{ duration: 0.2, ease: 'easeInOut' }}
82
- className='overflow-hidden border-t border-border/30 pt-2'
83
- >
84
- <div className='flex items-center justify-between'>
85
- <p
86
- id='brush-color-label'
87
- className='text-[11px] font-medium text-muted-foreground'
88
- >
89
- {t('toolbar.brushColor')}
90
- </p>
91
- <div className='flex items-center gap-2'>
92
- <span
93
- className='font-mono text-[10px] text-muted-foreground uppercase'
94
- aria-hidden='true'
95
  >
96
- {brushConfig.color}
97
- </span>
98
- <ColorPicker
99
- value={brushConfig.color}
100
- onChange={(color) => setBrushConfig({ color })}
101
- className='size-5 rounded-md'
102
- aria-labelledby='brush-color-label'
103
- />
 
 
 
 
 
 
 
 
104
  </div>
105
- </div>
106
- </motion.div>
107
- )}
108
- </AnimatePresence>
109
- </div>
110
- </motion.div>
111
  )}
112
  </AnimatePresence>
113
  )
 
30
  <AnimatePresence>
31
  {isBrushTool && (
32
  <motion.div
33
+ initial={{ x: -20, opacity: 0 }}
34
+ animate={{ x: 0, opacity: 1 }}
35
+ exit={{ x: -20, opacity: 0 }}
36
+ transition={{ duration: 0.2, ease: 'easeOut' }}
37
+ className='absolute top-14 left-11 z-50 ml-1 flex w-[260px] flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl'
38
+ data-testid='sub-tool-rail'
39
+ >
40
+ <div className='space-y-4 p-4'>
41
+ {/* Brush Size */}
42
+ <div className='space-y-2'>
43
+ <p id='brush-size-label' className='text-[11px] font-medium text-muted-foreground'>
44
+ {t('toolbar.brushSize')}
45
+ </p>
46
+ <div className='flex items-center gap-2'>
47
+ <Slider
48
+ min={8}
49
+ max={128}
50
+ step={4}
51
+ value={[localSize]}
52
+ onValueChange={(vals) => setLocalSize(vals[0] ?? localSize)}
53
+ onValueCommit={(vals) => setBrushConfig({ size: vals[0] ?? localSize })}
54
+ className='flex-1'
55
+ aria-labelledby='brush-size-label'
 
 
 
 
 
 
 
56
  />
57
+ <div className='flex shrink-0 items-center gap-1.5'>
58
+ <Input
59
+ value={localSize}
60
+ readOnly
61
+ aria-label='Brush size value'
62
+ className='h-8 w-11 border-border/50 bg-muted/20 px-1 text-center text-[11px]'
63
+ />
64
+ <span
65
+ className='w-4 text-[10px] font-medium text-muted-foreground'
66
+ aria-hidden='true'
67
+ >
68
+ px
69
+ </span>
70
+ </div>
71
  </div>
72
  </div>
 
73
 
74
+ {/* Color Picker Section */}
75
+ <AnimatePresence initial={false}>
76
+ {mode === 'brush' && (
77
+ <motion.div
78
+ initial={{ height: 0, opacity: 0 }}
79
+ animate={{ height: 'auto', opacity: 1 }}
80
+ exit={{ height: 0, opacity: 0 }}
81
+ transition={{ duration: 0.2, ease: 'easeInOut' }}
82
+ className='overflow-hidden border-t border-border/30 pt-2'
83
+ >
84
+ <div className='flex items-center justify-between'>
85
+ <p
86
+ id='brush-color-label'
87
+ className='text-[11px] font-medium text-muted-foreground'
 
 
 
 
 
 
 
88
  >
89
+ {t('toolbar.brushColor')}
90
+ </p>
91
+ <div className='flex items-center gap-2'>
92
+ <span
93
+ className='font-mono text-[10px] text-muted-foreground uppercase'
94
+ aria-hidden='true'
95
+ >
96
+ {brushConfig.color}
97
+ </span>
98
+ <ColorPicker
99
+ value={brushConfig.color}
100
+ onChange={(color) => setBrushConfig({ color })}
101
+ className='size-5 rounded-md'
102
+ aria-labelledby='brush-color-label'
103
+ />
104
+ </div>
105
  </div>
106
+ </motion.div>
107
+ )}
108
+ </AnimatePresence>
109
+ </div>
110
+ </motion.div>
 
111
  )}
112
  </AnimatePresence>
113
  )
ui/components/panels/AiPanel.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { LoaderCircleIcon, SparklesIcon } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { Button } from '@/components/ui/button'
8
+ import { Input } from '@/components/ui/input'
9
+ import { Label } from '@/components/ui/label'
10
+ import { Textarea } from '@/components/ui/textarea'
11
+ import { useCurrentPage } from '@/hooks/useCurrentPage'
12
+ import { startCodexImageGeneration, useGetCodexAuthStatus } from '@/lib/api/default/default'
13
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
14
+ import { useJobsStore } from '@/lib/stores/jobsStore'
15
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
16
+
17
+ export function AiPanel() {
18
+ const { t } = useTranslation()
19
+ const page = useCurrentPage()
20
+ const prompt = usePreferencesStore((s) => s.codexImagePrompt)
21
+ const setPrompt = usePreferencesStore((s) => s.setCodexImagePrompt)
22
+ const model = usePreferencesStore((s) => s.codexImageModel)
23
+ const setModel = usePreferencesStore((s) => s.setCodexImageModel)
24
+ const setShowRenderedImage = useEditorUiStore((s) => s.setShowRenderedImage)
25
+ const showError = useEditorUiStore((s) => s.showError)
26
+ const [busy, setBusy] = useState(false)
27
+ const { data: auth } = useGetCodexAuthStatus()
28
+ const isProcessing = useJobsStore((s) =>
29
+ Object.values(s.jobs).some((job) => job.status === 'running'),
30
+ )
31
+
32
+ const signedIn = auth?.signedIn === true
33
+ const promptReady = !!prompt?.trim()
34
+ const modelValue = model?.trim() || 'gpt-5.5'
35
+ const canGenerate = signedIn && !!page && promptReady && !isProcessing && !busy
36
+
37
+ const handleGenerate = async () => {
38
+ if (!signedIn || !page || !promptReady) return
39
+ setBusy(true)
40
+ try {
41
+ setShowRenderedImage(true)
42
+ await startCodexImageGeneration({
43
+ pageId: page.id,
44
+ prompt: prompt!.trim(),
45
+ model: modelValue,
46
+ quality: 'high',
47
+ })
48
+ } catch (err) {
49
+ showError(String(err))
50
+ } finally {
51
+ setBusy(false)
52
+ }
53
+ }
54
+
55
+ if (!signedIn) return null
56
+
57
+ return (
58
+ <div className='flex min-h-0 flex-col gap-3 text-xs'>
59
+ <div className='space-y-1.5'>
60
+ <Label className='text-[10px] font-semibold tracking-wide text-muted-foreground uppercase'>
61
+ {t('ai.model')}
62
+ </Label>
63
+ <Input
64
+ value={modelValue}
65
+ onChange={(event) => setModel(event.target.value || undefined)}
66
+ className='h-7 px-2 text-xs'
67
+ />
68
+ </div>
69
+
70
+ <div className='space-y-1.5'>
71
+ <Label className='text-[10px] font-semibold tracking-wide text-muted-foreground uppercase'>
72
+ {t('ai.prompt')}
73
+ </Label>
74
+ <Textarea
75
+ value={prompt ?? ''}
76
+ onChange={(event) => setPrompt(event.target.value || undefined)}
77
+ rows={8}
78
+ className='min-h-36 resize-y px-2 py-1.5 text-xs leading-snug md:text-xs'
79
+ />
80
+ </div>
81
+
82
+ <Button
83
+ className='w-full gap-1.5'
84
+ size='sm'
85
+ disabled={!canGenerate}
86
+ onClick={() => void handleGenerate()}
87
+ >
88
+ {busy || isProcessing ? (
89
+ <LoaderCircleIcon className='size-3.5 animate-spin' />
90
+ ) : (
91
+ <SparklesIcon className='size-3.5' />
92
+ )}
93
+ {t('ai.generate')}
94
+ </Button>
95
+
96
+ {!page && <p className='text-xs text-muted-foreground'>{t('ai.noPage')}</p>}
97
+ </div>
98
+ )
99
+ }
ui/lib/api/default/default.msw.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
@@ -8,6 +8,7 @@ import { HttpResponse, delay, http } from 'msw'
8
  import type { RequestHandlerOptions } from 'msw'
9
 
10
  import {
 
11
  FontSource,
12
  ImageRole,
13
  JobStatus,
@@ -23,6 +24,10 @@ import type {
23
  AddImageLayerResponse,
24
  AppConfig,
25
  AppEvent,
 
 
 
 
26
  CreatePagesResponse,
27
  DataConfig,
28
  EngineCatalog,
@@ -50,6 +55,60 @@ import type {
50
  Transform,
51
  } from '../schemas'
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  export const getGetBlobResponseMock = (): ArrayBuffer =>
54
  new ArrayBuffer(faker.number.int({ min: 1, max: 64 }))
55
 
@@ -1000,6 +1059,104 @@ export const getGetSceneJsonResponseMock = (
1000
  ...overrideResponse,
1001
  })
1002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  export const getGetBlobMockHandler = (
1004
  overrideResponse?:
1005
  | ArrayBuffer
@@ -1910,6 +2067,10 @@ export const getGetSceneJsonMockHandler = (
1910
  )
1911
  }
1912
  export const getDefaultMock = () => [
 
 
 
 
1913
  getGetBlobMockHandler(),
1914
  getGetConfigMockHandler(),
1915
  getPatchConfigMockHandler(),
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
8
  import type { RequestHandlerOptions } from 'msw'
9
 
10
  import {
11
+ CodexAuthAttemptStatus,
12
  FontSource,
13
  ImageRole,
14
  JobStatus,
 
24
  AddImageLayerResponse,
25
  AppConfig,
26
  AppEvent,
27
+ CodexAuthStatus,
28
+ CodexDeviceLogin,
29
+ CodexDeviceLoginStatus,
30
+ CodexImageGenerationResponse,
31
  CreatePagesResponse,
32
  DataConfig,
33
  EngineCatalog,
 
55
  Transform,
56
  } from '../schemas'
57
 
58
+ export const getStartCodexDeviceLoginResponseMock = (
59
+ overrideResponse: Partial<Extract<CodexDeviceLogin, object>> = {},
60
+ ): CodexDeviceLogin => ({
61
+ intervalSeconds: faker.number.int({ min: 0 }),
62
+ loginId: faker.string.alpha({ length: { min: 10, max: 20 } }),
63
+ timeoutSeconds: faker.number.int({ min: 0 }),
64
+ userCode: faker.string.alpha({ length: { min: 10, max: 20 } }),
65
+ verificationUrl: faker.string.alpha({ length: { min: 10, max: 20 } }),
66
+ ...overrideResponse,
67
+ })
68
+
69
+ export const getGetCodexAuthStatusResponseCodexDeviceLoginStatusMock = (
70
+ overrideResponse: Partial<CodexDeviceLoginStatus> = {},
71
+ ): CodexDeviceLoginStatus => ({
72
+ ...{
73
+ accountId: faker.helpers.arrayElement([
74
+ faker.helpers.arrayElement([faker.string.alpha({ length: { min: 10, max: 20 } }), null]),
75
+ undefined,
76
+ ]),
77
+ error: faker.helpers.arrayElement([
78
+ faker.helpers.arrayElement([faker.string.alpha({ length: { min: 10, max: 20 } }), null]),
79
+ undefined,
80
+ ]),
81
+ loginId: faker.string.alpha({ length: { min: 10, max: 20 } }),
82
+ status: faker.helpers.arrayElement(Object.values(CodexAuthAttemptStatus)),
83
+ },
84
+ ...overrideResponse,
85
+ })
86
+
87
+ export const getGetCodexAuthStatusResponseMock = (
88
+ overrideResponse: Partial<Extract<CodexAuthStatus, object>> = {},
89
+ ): CodexAuthStatus => ({
90
+ accountId: faker.helpers.arrayElement([
91
+ faker.helpers.arrayElement([faker.string.alpha({ length: { min: 10, max: 20 } }), null]),
92
+ undefined,
93
+ ]),
94
+ login: faker.helpers.arrayElement([
95
+ faker.helpers.arrayElement([
96
+ null,
97
+ { ...getGetCodexAuthStatusResponseCodexDeviceLoginStatusMock() },
98
+ ]),
99
+ undefined,
100
+ ]),
101
+ signedIn: faker.datatype.boolean(),
102
+ ...overrideResponse,
103
+ })
104
+
105
+ export const getStartCodexImageGenerationResponseMock = (
106
+ overrideResponse: Partial<Extract<CodexImageGenerationResponse, object>> = {},
107
+ ): CodexImageGenerationResponse => ({
108
+ operationId: faker.string.alpha({ length: { min: 10, max: 20 } }),
109
+ ...overrideResponse,
110
+ })
111
+
112
  export const getGetBlobResponseMock = (): ArrayBuffer =>
113
  new ArrayBuffer(faker.number.int({ min: 1, max: 64 }))
114
 
 
1059
  ...overrideResponse,
1060
  })
1061
 
1062
+ export const getStartCodexDeviceLoginMockHandler = (
1063
+ overrideResponse?:
1064
+ | CodexDeviceLogin
1065
+ | ((
1066
+ info: Parameters<Parameters<typeof http.post>[1]>[0],
1067
+ ) => Promise<CodexDeviceLogin> | CodexDeviceLogin),
1068
+ options?: RequestHandlerOptions,
1069
+ ) => {
1070
+ return http.post(
1071
+ '*/ai/codex/auth/device-code',
1072
+ async (info: Parameters<Parameters<typeof http.post>[1]>[0]) => {
1073
+ await delay(0)
1074
+
1075
+ return HttpResponse.json(
1076
+ overrideResponse !== undefined
1077
+ ? typeof overrideResponse === 'function'
1078
+ ? await overrideResponse(info)
1079
+ : overrideResponse
1080
+ : getStartCodexDeviceLoginResponseMock(),
1081
+ { status: 200 },
1082
+ )
1083
+ },
1084
+ options,
1085
+ )
1086
+ }
1087
+
1088
+ export const getDeleteCodexSessionMockHandler = (
1089
+ overrideResponse?:
1090
+ | void
1091
+ | ((info: Parameters<Parameters<typeof http.delete>[1]>[0]) => Promise<void> | void),
1092
+ options?: RequestHandlerOptions,
1093
+ ) => {
1094
+ return http.delete(
1095
+ '*/ai/codex/auth/session',
1096
+ async (info: Parameters<Parameters<typeof http.delete>[1]>[0]) => {
1097
+ await delay(0)
1098
+ if (typeof overrideResponse === 'function') {
1099
+ await overrideResponse(info)
1100
+ }
1101
+
1102
+ return new HttpResponse(null, { status: 204 })
1103
+ },
1104
+ options,
1105
+ )
1106
+ }
1107
+
1108
+ export const getGetCodexAuthStatusMockHandler = (
1109
+ overrideResponse?:
1110
+ | CodexAuthStatus
1111
+ | ((
1112
+ info: Parameters<Parameters<typeof http.get>[1]>[0],
1113
+ ) => Promise<CodexAuthStatus> | CodexAuthStatus),
1114
+ options?: RequestHandlerOptions,
1115
+ ) => {
1116
+ return http.get(
1117
+ '*/ai/codex/auth/status',
1118
+ async (info: Parameters<Parameters<typeof http.get>[1]>[0]) => {
1119
+ await delay(0)
1120
+
1121
+ return HttpResponse.json(
1122
+ overrideResponse !== undefined
1123
+ ? typeof overrideResponse === 'function'
1124
+ ? await overrideResponse(info)
1125
+ : overrideResponse
1126
+ : getGetCodexAuthStatusResponseMock(),
1127
+ { status: 200 },
1128
+ )
1129
+ },
1130
+ options,
1131
+ )
1132
+ }
1133
+
1134
+ export const getStartCodexImageGenerationMockHandler = (
1135
+ overrideResponse?:
1136
+ | CodexImageGenerationResponse
1137
+ | ((
1138
+ info: Parameters<Parameters<typeof http.post>[1]>[0],
1139
+ ) => Promise<CodexImageGenerationResponse> | CodexImageGenerationResponse),
1140
+ options?: RequestHandlerOptions,
1141
+ ) => {
1142
+ return http.post(
1143
+ '*/ai/codex/images',
1144
+ async (info: Parameters<Parameters<typeof http.post>[1]>[0]) => {
1145
+ await delay(0)
1146
+
1147
+ return HttpResponse.json(
1148
+ overrideResponse !== undefined
1149
+ ? typeof overrideResponse === 'function'
1150
+ ? await overrideResponse(info)
1151
+ : overrideResponse
1152
+ : getStartCodexImageGenerationResponseMock(),
1153
+ { status: 200 },
1154
+ )
1155
+ },
1156
+ options,
1157
+ )
1158
+ }
1159
+
1160
  export const getGetBlobMockHandler = (
1161
  overrideResponse?:
1162
  | ArrayBuffer
 
2067
  )
2068
  }
2069
  export const getDefaultMock = () => [
2070
+ getStartCodexDeviceLoginMockHandler(),
2071
+ getDeleteCodexSessionMockHandler(),
2072
+ getGetCodexAuthStatusMockHandler(),
2073
+ getStartCodexImageGenerationMockHandler(),
2074
  getGetBlobMockHandler(),
2075
  getGetConfigMockHandler(),
2076
  getPatchConfigMockHandler(),
ui/lib/api/default/default.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
@@ -24,6 +24,10 @@ import type {
24
  AddImageLayerResponse,
25
  AppConfig,
26
  AppEvent,
 
 
 
 
27
  ConfigPatch,
28
  CreatePagesFromPathsRequest,
29
  CreatePagesResponse,
@@ -56,6 +60,313 @@ import type {
56
 
57
  type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  export const getGetBlobUrl = (hash: string) => {
60
  return `/api/v1/blobs/${hash}`
61
  }
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
24
  AddImageLayerResponse,
25
  AppConfig,
26
  AppEvent,
27
+ CodexAuthStatus,
28
+ CodexDeviceLogin,
29
+ CodexImageGenerationOptions,
30
+ CodexImageGenerationResponse,
31
  ConfigPatch,
32
  CreatePagesFromPathsRequest,
33
  CreatePagesResponse,
 
60
 
61
  type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1]
62
 
63
+ export const getStartCodexDeviceLoginUrl = () => {
64
+ return `/api/v1/ai/codex/auth/device-code`
65
+ }
66
+
67
+ export const startCodexDeviceLogin = async (options?: RequestInit): Promise<CodexDeviceLogin> => {
68
+ return fetchApi<CodexDeviceLogin>(getStartCodexDeviceLoginUrl(), {
69
+ ...options,
70
+ method: 'POST',
71
+ })
72
+ }
73
+
74
+ export const getStartCodexDeviceLoginMutationOptions = <
75
+ TError = unknown,
76
+ TContext = unknown,
77
+ >(options?: {
78
+ mutation?: UseMutationOptions<
79
+ Awaited<ReturnType<typeof startCodexDeviceLogin>>,
80
+ TError,
81
+ void,
82
+ TContext
83
+ >
84
+ request?: SecondParameter<typeof fetchApi>
85
+ }): UseMutationOptions<
86
+ Awaited<ReturnType<typeof startCodexDeviceLogin>>,
87
+ TError,
88
+ void,
89
+ TContext
90
+ > => {
91
+ const mutationKey = ['startCodexDeviceLogin']
92
+ const { mutation: mutationOptions, request: requestOptions } = options
93
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
94
+ ? options
95
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
96
+ : { mutation: { mutationKey }, request: undefined }
97
+
98
+ const mutationFn: MutationFunction<
99
+ Awaited<ReturnType<typeof startCodexDeviceLogin>>,
100
+ void
101
+ > = () => {
102
+ return startCodexDeviceLogin(requestOptions)
103
+ }
104
+
105
+ return { mutationFn, ...mutationOptions }
106
+ }
107
+
108
+ export type StartCodexDeviceLoginMutationResult = NonNullable<
109
+ Awaited<ReturnType<typeof startCodexDeviceLogin>>
110
+ >
111
+
112
+ export type StartCodexDeviceLoginMutationError = unknown
113
+
114
+ export const useStartCodexDeviceLogin = <TError = unknown, TContext = unknown>(
115
+ options?: {
116
+ mutation?: UseMutationOptions<
117
+ Awaited<ReturnType<typeof startCodexDeviceLogin>>,
118
+ TError,
119
+ void,
120
+ TContext
121
+ >
122
+ request?: SecondParameter<typeof fetchApi>
123
+ },
124
+ queryClient?: QueryClient,
125
+ ): UseMutationResult<Awaited<ReturnType<typeof startCodexDeviceLogin>>, TError, void, TContext> => {
126
+ return useMutation(getStartCodexDeviceLoginMutationOptions(options), queryClient)
127
+ }
128
+ export const getDeleteCodexSessionUrl = () => {
129
+ return `/api/v1/ai/codex/auth/session`
130
+ }
131
+
132
+ export const deleteCodexSession = async (options?: RequestInit): Promise<void> => {
133
+ return fetchApi<void>(getDeleteCodexSessionUrl(), {
134
+ ...options,
135
+ method: 'DELETE',
136
+ })
137
+ }
138
+
139
+ export const getDeleteCodexSessionMutationOptions = <
140
+ TError = unknown,
141
+ TContext = unknown,
142
+ >(options?: {
143
+ mutation?: UseMutationOptions<
144
+ Awaited<ReturnType<typeof deleteCodexSession>>,
145
+ TError,
146
+ void,
147
+ TContext
148
+ >
149
+ request?: SecondParameter<typeof fetchApi>
150
+ }): UseMutationOptions<Awaited<ReturnType<typeof deleteCodexSession>>, TError, void, TContext> => {
151
+ const mutationKey = ['deleteCodexSession']
152
+ const { mutation: mutationOptions, request: requestOptions } = options
153
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
154
+ ? options
155
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
156
+ : { mutation: { mutationKey }, request: undefined }
157
+
158
+ const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteCodexSession>>, void> = () => {
159
+ return deleteCodexSession(requestOptions)
160
+ }
161
+
162
+ return { mutationFn, ...mutationOptions }
163
+ }
164
+
165
+ export type DeleteCodexSessionMutationResult = NonNullable<
166
+ Awaited<ReturnType<typeof deleteCodexSession>>
167
+ >
168
+
169
+ export type DeleteCodexSessionMutationError = unknown
170
+
171
+ export const useDeleteCodexSession = <TError = unknown, TContext = unknown>(
172
+ options?: {
173
+ mutation?: UseMutationOptions<
174
+ Awaited<ReturnType<typeof deleteCodexSession>>,
175
+ TError,
176
+ void,
177
+ TContext
178
+ >
179
+ request?: SecondParameter<typeof fetchApi>
180
+ },
181
+ queryClient?: QueryClient,
182
+ ): UseMutationResult<Awaited<ReturnType<typeof deleteCodexSession>>, TError, void, TContext> => {
183
+ return useMutation(getDeleteCodexSessionMutationOptions(options), queryClient)
184
+ }
185
+ export const getGetCodexAuthStatusUrl = () => {
186
+ return `/api/v1/ai/codex/auth/status`
187
+ }
188
+
189
+ export const getCodexAuthStatus = async (options?: RequestInit): Promise<CodexAuthStatus> => {
190
+ return fetchApi<CodexAuthStatus>(getGetCodexAuthStatusUrl(), {
191
+ ...options,
192
+ method: 'GET',
193
+ })
194
+ }
195
+
196
+ export const getGetCodexAuthStatusQueryKey = () => {
197
+ return [`/api/v1/ai/codex/auth/status`] as const
198
+ }
199
+
200
+ export const getGetCodexAuthStatusQueryOptions = <
201
+ TData = Awaited<ReturnType<typeof getCodexAuthStatus>>,
202
+ TError = unknown,
203
+ >(options?: {
204
+ query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getCodexAuthStatus>>, TError, TData>>
205
+ request?: SecondParameter<typeof fetchApi>
206
+ }) => {
207
+ const { query: queryOptions, request: requestOptions } = options ?? {}
208
+
209
+ const queryKey = queryOptions?.queryKey ?? getGetCodexAuthStatusQueryKey()
210
+
211
+ const queryFn: QueryFunction<Awaited<ReturnType<typeof getCodexAuthStatus>>> = ({ signal }) =>
212
+ getCodexAuthStatus({ signal, ...requestOptions })
213
+
214
+ return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions } as UseQueryOptions<
215
+ Awaited<ReturnType<typeof getCodexAuthStatus>>,
216
+ TError,
217
+ TData
218
+ > & { queryKey: DataTag<QueryKey, TData, TError> }
219
+ }
220
+
221
+ export type GetCodexAuthStatusQueryResult = NonNullable<
222
+ Awaited<ReturnType<typeof getCodexAuthStatus>>
223
+ >
224
+ export type GetCodexAuthStatusQueryError = unknown
225
+
226
+ export function useGetCodexAuthStatus<
227
+ TData = Awaited<ReturnType<typeof getCodexAuthStatus>>,
228
+ TError = unknown,
229
+ >(
230
+ options: {
231
+ query: Partial<UseQueryOptions<Awaited<ReturnType<typeof getCodexAuthStatus>>, TError, TData>> &
232
+ Pick<
233
+ DefinedInitialDataOptions<
234
+ Awaited<ReturnType<typeof getCodexAuthStatus>>,
235
+ TError,
236
+ Awaited<ReturnType<typeof getCodexAuthStatus>>
237
+ >,
238
+ 'initialData'
239
+ >
240
+ request?: SecondParameter<typeof fetchApi>
241
+ },
242
+ queryClient?: QueryClient,
243
+ ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
244
+ export function useGetCodexAuthStatus<
245
+ TData = Awaited<ReturnType<typeof getCodexAuthStatus>>,
246
+ TError = unknown,
247
+ >(
248
+ options?: {
249
+ query?: Partial<
250
+ UseQueryOptions<Awaited<ReturnType<typeof getCodexAuthStatus>>, TError, TData>
251
+ > &
252
+ Pick<
253
+ UndefinedInitialDataOptions<
254
+ Awaited<ReturnType<typeof getCodexAuthStatus>>,
255
+ TError,
256
+ Awaited<ReturnType<typeof getCodexAuthStatus>>
257
+ >,
258
+ 'initialData'
259
+ >
260
+ request?: SecondParameter<typeof fetchApi>
261
+ },
262
+ queryClient?: QueryClient,
263
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
264
+ export function useGetCodexAuthStatus<
265
+ TData = Awaited<ReturnType<typeof getCodexAuthStatus>>,
266
+ TError = unknown,
267
+ >(
268
+ options?: {
269
+ query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getCodexAuthStatus>>, TError, TData>>
270
+ request?: SecondParameter<typeof fetchApi>
271
+ },
272
+ queryClient?: QueryClient,
273
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
274
+
275
+ export function useGetCodexAuthStatus<
276
+ TData = Awaited<ReturnType<typeof getCodexAuthStatus>>,
277
+ TError = unknown,
278
+ >(
279
+ options?: {
280
+ query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof getCodexAuthStatus>>, TError, TData>>
281
+ request?: SecondParameter<typeof fetchApi>
282
+ },
283
+ queryClient?: QueryClient,
284
+ ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
285
+ const queryOptions = getGetCodexAuthStatusQueryOptions(options)
286
+
287
+ const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
288
+ queryKey: DataTag<QueryKey, TData, TError>
289
+ }
290
+
291
+ return { ...query, queryKey: queryOptions.queryKey }
292
+ }
293
+ export const getStartCodexImageGenerationUrl = () => {
294
+ return `/api/v1/ai/codex/images`
295
+ }
296
+
297
+ export const startCodexImageGeneration = async (
298
+ codexImageGenerationOptions: CodexImageGenerationOptions,
299
+ options?: RequestInit,
300
+ ): Promise<CodexImageGenerationResponse> => {
301
+ return fetchApi<CodexImageGenerationResponse>(getStartCodexImageGenerationUrl(), {
302
+ ...options,
303
+ method: 'POST',
304
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
305
+ body: JSON.stringify(codexImageGenerationOptions),
306
+ })
307
+ }
308
+
309
+ export const getStartCodexImageGenerationMutationOptions = <
310
+ TError = unknown,
311
+ TContext = unknown,
312
+ >(options?: {
313
+ mutation?: UseMutationOptions<
314
+ Awaited<ReturnType<typeof startCodexImageGeneration>>,
315
+ TError,
316
+ { data: CodexImageGenerationOptions },
317
+ TContext
318
+ >
319
+ request?: SecondParameter<typeof fetchApi>
320
+ }): UseMutationOptions<
321
+ Awaited<ReturnType<typeof startCodexImageGeneration>>,
322
+ TError,
323
+ { data: CodexImageGenerationOptions },
324
+ TContext
325
+ > => {
326
+ const mutationKey = ['startCodexImageGeneration']
327
+ const { mutation: mutationOptions, request: requestOptions } = options
328
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
329
+ ? options
330
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
331
+ : { mutation: { mutationKey }, request: undefined }
332
+
333
+ const mutationFn: MutationFunction<
334
+ Awaited<ReturnType<typeof startCodexImageGeneration>>,
335
+ { data: CodexImageGenerationOptions }
336
+ > = (props) => {
337
+ const { data } = props ?? {}
338
+
339
+ return startCodexImageGeneration(data, requestOptions)
340
+ }
341
+
342
+ return { mutationFn, ...mutationOptions }
343
+ }
344
+
345
+ export type StartCodexImageGenerationMutationResult = NonNullable<
346
+ Awaited<ReturnType<typeof startCodexImageGeneration>>
347
+ >
348
+ export type StartCodexImageGenerationMutationBody = CodexImageGenerationOptions
349
+ export type StartCodexImageGenerationMutationError = unknown
350
+
351
+ export const useStartCodexImageGeneration = <TError = unknown, TContext = unknown>(
352
+ options?: {
353
+ mutation?: UseMutationOptions<
354
+ Awaited<ReturnType<typeof startCodexImageGeneration>>,
355
+ TError,
356
+ { data: CodexImageGenerationOptions },
357
+ TContext
358
+ >
359
+ request?: SecondParameter<typeof fetchApi>
360
+ },
361
+ queryClient?: QueryClient,
362
+ ): UseMutationResult<
363
+ Awaited<ReturnType<typeof startCodexImageGeneration>>,
364
+ TError,
365
+ { data: CodexImageGenerationOptions },
366
+ TContext
367
+ > => {
368
+ return useMutation(getStartCodexImageGenerationMutationOptions(options), queryClient)
369
+ }
370
  export const getGetBlobUrl = (hash: string) => {
371
  return `/api/v1/blobs/${hash}`
372
  }
ui/lib/api/schemas/addImageLayerResponse.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/appConfig.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/appEvent.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/blobRef.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/codexAuthAttemptStatus.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export type CodexAuthAttemptStatus =
8
+ (typeof CodexAuthAttemptStatus)[keyof typeof CodexAuthAttemptStatus]
9
+
10
+ export const CodexAuthAttemptStatus = {
11
+ pending: 'pending',
12
+ succeeded: 'succeeded',
13
+ failed: 'failed',
14
+ } as const
ui/lib/api/schemas/codexAuthStatus.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import type { CodexDeviceLoginStatus } from './codexDeviceLoginStatus'
7
+
8
+ export interface CodexAuthStatus {
9
+ /** @nullable */
10
+ accountId?: string | null
11
+ login?: null | CodexDeviceLoginStatus
12
+ signedIn: boolean
13
+ }
ui/lib/api/schemas/codexDeviceLogin.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface CodexDeviceLogin {
8
+ /** @minimum 0 */
9
+ intervalSeconds: number
10
+ loginId: string
11
+ /** @minimum 0 */
12
+ timeoutSeconds: number
13
+ userCode: string
14
+ verificationUrl: string
15
+ }
ui/lib/api/schemas/codexDeviceLoginStatus.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import type { CodexAuthAttemptStatus } from './codexAuthAttemptStatus'
7
+
8
+ export interface CodexDeviceLoginStatus {
9
+ /** @nullable */
10
+ accountId?: string | null
11
+ /** @nullable */
12
+ error?: string | null
13
+ loginId: string
14
+ status: CodexAuthAttemptStatus
15
+ }
ui/lib/api/schemas/codexImageGenerationOptions.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+ import type { PageId } from './pageId'
7
+
8
+ export interface CodexImageGenerationOptions {
9
+ /** @nullable */
10
+ instructions?: string | null
11
+ /** @nullable */
12
+ model?: string | null
13
+ pageId: PageId
14
+ prompt: string
15
+ /** @nullable */
16
+ quality?: string | null
17
+ /** @nullable */
18
+ size?: string | null
19
+ }
ui/lib/api/schemas/codexImageGenerationResponse.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
+ * Do not edit manually.
4
+ * OpenAPI spec version: 0.0.1
5
+ */
6
+
7
+ export interface CodexImageGenerationResponse {
8
+ operationId: string
9
+ }
ui/lib/api/schemas/configPatch.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/createPagesFromPathsRequest.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/createPagesResponse.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/createProjectRequest.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/dataConfig.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/dataConfigPatch.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/downloadProgress.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/downloadStatus.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/engineCatalog.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/engineCatalogEntry.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/exportFormat.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/exportProjectRequest.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/fontFaceInfo.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/fontPrediction.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/fontSource.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/googleFontCatalog.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/googleFontEntry.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
ui/lib/api/schemas/googleFontVariant.ts CHANGED
@@ -1,5 +1,5 @@
1
  /**
2
- * Generated by orval v8.8.0 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */
 
1
  /**
2
+ * Generated by orval v8.8.1 ๐Ÿบ
3
  * Do not edit manually.
4
  * OpenAPI spec version: 0.0.1
5
  */