Mayo commited on
feat: Codex integration
Browse filesThis view is limited to 50 files because it contains too many changes. ย See raw diff
- Cargo.lock +3 -0
- koharu-ai/Cargo.toml +1 -0
- koharu-ai/bin/codex.rs +6 -74
- koharu-ai/src/codex/client.rs +37 -0
- koharu-ai/src/codex/image.rs +385 -0
- koharu-ai/src/codex/mod.rs +2 -1
- koharu-ai/src/lib.rs +3 -0
- koharu-ai/src/provider.rs +50 -0
- koharu-app/Cargo.toml +2 -0
- koharu-app/src/ai.rs +431 -0
- koharu-app/src/app.rs +4 -0
- koharu-app/src/lib.rs +2 -0
- koharu-rpc/src/api.rs +1 -0
- koharu-rpc/src/routes/ai.rs +133 -0
- koharu-rpc/src/routes/mod.rs +1 -0
- koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap +24 -0
- ui/components/Panels.tsx +56 -3
- ui/components/SettingsDialog.tsx +193 -0
- ui/components/canvas/SubToolRail.tsx +72 -72
- ui/components/panels/AiPanel.tsx +99 -0
- ui/lib/api/default/default.msw.ts +162 -1
- ui/lib/api/default/default.ts +312 -1
- ui/lib/api/schemas/addImageLayerResponse.ts +1 -1
- ui/lib/api/schemas/appConfig.ts +1 -1
- ui/lib/api/schemas/appEvent.ts +1 -1
- ui/lib/api/schemas/blobRef.ts +1 -1
- ui/lib/api/schemas/codexAuthAttemptStatus.ts +14 -0
- ui/lib/api/schemas/codexAuthStatus.ts +13 -0
- ui/lib/api/schemas/codexDeviceLogin.ts +15 -0
- ui/lib/api/schemas/codexDeviceLoginStatus.ts +15 -0
- ui/lib/api/schemas/codexImageGenerationOptions.ts +19 -0
- ui/lib/api/schemas/codexImageGenerationResponse.ts +9 -0
- ui/lib/api/schemas/configPatch.ts +1 -1
- ui/lib/api/schemas/createPagesFromPathsRequest.ts +1 -1
- ui/lib/api/schemas/createPagesResponse.ts +1 -1
- ui/lib/api/schemas/createProjectRequest.ts +1 -1
- ui/lib/api/schemas/dataConfig.ts +1 -1
- ui/lib/api/schemas/dataConfigPatch.ts +1 -1
- ui/lib/api/schemas/downloadProgress.ts +1 -1
- ui/lib/api/schemas/downloadStatus.ts +1 -1
- ui/lib/api/schemas/engineCatalog.ts +1 -1
- ui/lib/api/schemas/engineCatalogEntry.ts +1 -1
- ui/lib/api/schemas/exportFormat.ts +1 -1
- ui/lib/api/schemas/exportProjectRequest.ts +1 -1
- ui/lib/api/schemas/fontFaceInfo.ts +1 -1
- ui/lib/api/schemas/fontPrediction.ts +1 -1
- ui/lib/api/schemas/fontSource.ts +1 -1
- ui/lib/api/schemas/googleFontCatalog.ts +1 -1
- ui/lib/api/schemas/googleFontEntry.ts +1 -1
- 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
|
| 211 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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 |
-
<
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
-
</div>
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 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 |
-
{
|
| 97 |
-
</
|
| 98 |
-
<
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</div>
|
| 105 |
-
</div>
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
</
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
*/
|