mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 21:10:58 -05:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff94be8c0 | ||
|
|
54246fe957 | ||
|
|
ff98e9b5b0 | ||
|
|
168977c44d | ||
|
|
f872ca4a6e | ||
|
|
5160019733 | ||
|
|
d5b26784cb | ||
|
|
617f02ec15 | ||
|
|
afa4c9acb8 | ||
|
|
b233825aac | ||
|
|
e409f3b11d | ||
|
|
9bafc23a41 |
Generated
+227
-12
@@ -750,6 +750,28 @@ dependencies = [
|
||||
"worldlabs_consumer_client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft-mcp"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_client",
|
||||
"base64 0.22.1",
|
||||
"directories",
|
||||
"enums",
|
||||
"reqwest 0.12.23",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokens",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_api_defs"
|
||||
version = "0.0.1"
|
||||
@@ -1179,9 +1201,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.80"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2959,6 +2981,16 @@ dependencies = [
|
||||
"darling_macro 0.20.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
@@ -2987,6 +3019,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
@@ -3009,6 +3054,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@@ -3628,9 +3684,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.16"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "easyenv"
|
||||
@@ -6899,6 +6955,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
version = "0.1.10"
|
||||
@@ -7515,6 +7580,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -8443,6 +8517,12 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -9582,6 +9662,26 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -9883,6 +9983,41 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures 0.3.32",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.20.1"
|
||||
@@ -10340,13 +10475,27 @@ checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"indexmap 1.9.3",
|
||||
"schemars_derive",
|
||||
"schemars_derive 0.8.21",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.22.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "0.8.21"
|
||||
@@ -10359,6 +10508,18 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schnellru"
|
||||
version = "0.2.4"
|
||||
@@ -10998,6 +11159,15 @@ dependencies = [
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_env_var_config"
|
||||
version = "0.0.1"
|
||||
@@ -12339,7 +12509,7 @@ dependencies = [
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"semver 1.0.23",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -12399,7 +12569,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
@@ -12435,7 +12605,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -12457,7 +12627,7 @@ dependencies = [
|
||||
"http 1.3.1",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -12503,7 +12673,7 @@ dependencies = [
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"open",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -12608,7 +12778,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"semver 1.0.23",
|
||||
"serde",
|
||||
"serde-untagged",
|
||||
@@ -12755,6 +12925,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thumbnail_generator"
|
||||
version = "0.1.0"
|
||||
@@ -13266,6 +13445,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13768,6 +13977,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "value-bag"
|
||||
version = "1.12.0"
|
||||
@@ -15116,7 +15331,7 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
"rsa",
|
||||
"rustls-pki-types",
|
||||
"schemars",
|
||||
"schemars 0.8.21",
|
||||
"scopeguard",
|
||||
"security-framework 2.10.0",
|
||||
"semver 1.0.23",
|
||||
|
||||
@@ -30,6 +30,7 @@ members = [
|
||||
"crates/api_clients/worldlabs_consumer_client",
|
||||
|
||||
# CLI tools for operations, etc.
|
||||
"crates/cli/artcraft-mcp",
|
||||
"crates/cli/devenv/dev-database-seed",
|
||||
"crates/cli/devenv/dev-upload-media-file",
|
||||
"crates/cli/production/db-backfill",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "artcraft-mcp"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "artcraft-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# MCP SDK
|
||||
rmcp = { version = "1.7.0", features = ["server", "macros", "transport-io", "schemars"] }
|
||||
schemars = "1.0"
|
||||
|
||||
# Async + logging
|
||||
tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "io-std", "time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
# Serde + errors
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
anyhow = "1.0.102"
|
||||
|
||||
# Home directory resolution (matches desktop crate)
|
||||
directories = "6.0.0"
|
||||
|
||||
# Idempotency token generation — backend rejects requests without a v4 UUID
|
||||
uuid = { version = "1.8.0", features = ["v4"] }
|
||||
|
||||
# Fetch generated image bytes + base64-encode for MCP image content
|
||||
reqwest = { version = "0.12.23", default-features = false, features = ["rustls-tls", "gzip"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
# Internal crates — path deps into the parent workspace.
|
||||
# Cargo resolves their own `.workspace = true` deps against the root
|
||||
# workspace at storyteller-rust/Cargo.toml (Cargo walks up from each
|
||||
# path-dep target), so we don't have to mirror those.
|
||||
artcraft_client = { path = "../../api_clients/artcraft/artcraft_client" }
|
||||
artcraft_api_defs = { path = "../../api_clients/artcraft/artcraft_api_defs" }
|
||||
enums = { path = "../../schema/public/enums" }
|
||||
tokens = { path = "../../schema/public/tokens" }
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::errors::ToolError;
|
||||
|
||||
const STORYTELLER_API_HOST: &str = "api.storyteller.ai";
|
||||
|
||||
/// Shared entry for every tool: returns the API host plus loaded session
|
||||
/// cookies, or a structured ToolError (NOT_LOGGED_IN / ARTCRAFT_NOT_INSTALLED)
|
||||
/// with a clear remediation if the desktop app hasn't been signed in.
|
||||
pub fn load_session() -> Result<(ApiHost, StorytellerCredentialSet), ToolError> {
|
||||
let creds = load_storyteller_credentials().map_err(|e| ToolError::internal(format!("{:#}", e)))?;
|
||||
if creds.is_empty() {
|
||||
return Err(ToolError::not_logged_in());
|
||||
}
|
||||
Ok((ApiHost::Storyteller, creds))
|
||||
}
|
||||
|
||||
fn load_storyteller_credentials() -> Result<StorytellerCredentialSet> {
|
||||
// Production desktop app keeps Storyteller cookies in the Tauri HTTP
|
||||
// plugin's cookie jar at ~/Library/Caches/ai.artcraft.app/.cookies.
|
||||
// The text-file path under ~/Artcraft/credentials/ is wired up in the
|
||||
// dev source but the `persist_all_to_disk()` call is commented out,
|
||||
// so shipped builds never populate those files. Try the cookie jar
|
||||
// first, then fall back to the text files for forward-compat.
|
||||
if let Some(creds) = try_load_from_tauri_cookies_jar()? {
|
||||
if !creds.is_empty() {
|
||||
tracing::info!("loaded storyteller cookies from tauri http plugin jar");
|
||||
return Ok(creds);
|
||||
}
|
||||
}
|
||||
|
||||
let creds_dir = home_dir()?.join("Artcraft").join("credentials");
|
||||
let session = read_trimmed(&creds_dir.join("artcraft_session.txt"))?
|
||||
.map(StorytellerSessionCookie::new);
|
||||
let avt =
|
||||
read_trimmed(&creds_dir.join("artcraft_avt.txt"))?.map(StorytellerAvtCookie::new);
|
||||
Ok(StorytellerCredentialSet::initialize(avt, session))
|
||||
}
|
||||
|
||||
fn try_load_from_tauri_cookies_jar() -> Result<Option<StorytellerCredentialSet>> {
|
||||
let Some(path) = tauri_cookies_jar_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
let entries: Vec<CookieEntry> = serde_json::from_str(&raw)?;
|
||||
|
||||
let mut session = None;
|
||||
let mut visitor = None;
|
||||
for entry in entries {
|
||||
if !entry.domain_is(STORYTELLER_API_HOST) {
|
||||
continue;
|
||||
}
|
||||
let Some((name, value)) = split_name_value(&entry.raw_cookie) else {
|
||||
continue;
|
||||
};
|
||||
match name {
|
||||
"session" if session.is_none() => {
|
||||
session = Some(StorytellerSessionCookie::new(value.to_string()));
|
||||
}
|
||||
"visitor" if visitor.is_none() => {
|
||||
visitor = Some(StorytellerAvtCookie::new(value.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(StorytellerCredentialSet::initialize(visitor, session)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CookieEntry {
|
||||
raw_cookie: String,
|
||||
#[serde(default)]
|
||||
domain: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CookieEntry {
|
||||
fn domain_is(&self, host: &str) -> bool {
|
||||
if let Some(s) = self.domain.get("HostOnly").and_then(|v| v.as_str()) {
|
||||
return s.eq_ignore_ascii_case(host);
|
||||
}
|
||||
if let Some(s) = self.domain.get("Suffix").and_then(|v| v.as_str()) {
|
||||
return host.eq_ignore_ascii_case(s) || host.ends_with(&format!(".{}", s));
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn split_name_value(raw_cookie: &str) -> Option<(&str, &str)> {
|
||||
let head = raw_cookie.split(';').next()?.trim();
|
||||
let eq = head.find('=')?;
|
||||
Some((&head[..eq], &head[eq + 1..]))
|
||||
}
|
||||
|
||||
fn tauri_cookies_jar_path() -> Option<PathBuf> {
|
||||
// Tauri's app_cache_dir() on macOS is ~/Library/Caches/<bundle_id>.
|
||||
// Bundle id is ai.artcraft.app — see Info.plist of the installed app.
|
||||
// ProjectDirs::from("ai", "artcraft", "app").cache_dir() resolves to
|
||||
// the same path on macOS. Linux/Windows resolutions differ from
|
||||
// Tauri's; deferred until we need them.
|
||||
let dirs = directories::ProjectDirs::from("ai", "artcraft", "app")?;
|
||||
Some(dirs.cache_dir().join(".cookies"))
|
||||
}
|
||||
|
||||
fn read_trimmed(path: &PathBuf) -> Result<Option<String>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> Result<PathBuf> {
|
||||
directories::UserDirs::new()
|
||||
.map(|d| d.home_dir().to_path_buf())
|
||||
.ok_or_else(|| anyhow!("could not determine home directory"))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
/// Artcraft desktop app hasn't been installed (or hasn't been run).
|
||||
ArtcraftNotInstalled,
|
||||
/// Desktop app is present but the user isn't signed in.
|
||||
NotLoggedIn,
|
||||
/// Session cookies present but the backend rejected them (likely expired).
|
||||
SessionExpired,
|
||||
/// Storyteller backend unreachable or returned an unexpected error.
|
||||
BackendUnavailable,
|
||||
/// Caller-provided parameters didn't pass validation.
|
||||
InvalidParams,
|
||||
/// Generation didn't complete inside our wait window.
|
||||
GenerationTimeout,
|
||||
/// Generation completed with a failure status.
|
||||
GenerationFailed,
|
||||
/// Reference image fetch / validation / upload failed.
|
||||
ReferenceImageFailed,
|
||||
/// Catch-all for unexpected errors.
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ToolError {
|
||||
pub error_code: ErrorCode,
|
||||
pub message: String,
|
||||
pub remediation: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
pub fn not_logged_in() -> Self {
|
||||
if is_artcraft_app_installed() {
|
||||
Self {
|
||||
error_code: ErrorCode::NotLoggedIn,
|
||||
message: "You're not signed in to Artcraft.".to_string(),
|
||||
remediation:
|
||||
"Open /Applications/ArtCraft.app and sign in to your account, then ask me again."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
error_code: ErrorCode::ArtcraftNotInstalled,
|
||||
message: "The Artcraft desktop app isn't installed.".to_string(),
|
||||
remediation:
|
||||
"Download it from https://getartcraft.com, install, sign in, then ask me again."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_expired() -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::SessionExpired,
|
||||
message: "Your Artcraft session has expired or been invalidated.".to_string(),
|
||||
remediation: "Open the Artcraft desktop app and sign in again, then ask me to retry."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backend(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::BackendUnavailable,
|
||||
message: message.into(),
|
||||
remediation:
|
||||
"This usually clears up on its own. Try again in a minute. If it persists, check the Artcraft desktop app — if it's also failing the backend is having issues."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_params(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::InvalidParams,
|
||||
message: message.into(),
|
||||
remediation:
|
||||
"Call list_image_models to see valid model ids and per-model constraints, then retry with corrected parameters."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generation_timeout(token: String) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::GenerationTimeout,
|
||||
message: "Generation did not complete within 90 seconds.".to_string(),
|
||||
remediation:
|
||||
"The job may still be running on the Artcraft backend — check the desktop app. Or try a faster model (call list_image_models to compare options)."
|
||||
.to_string(),
|
||||
details: Some(serde_json::json!({ "inference_job_token": token })),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generation_failed(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::GenerationFailed,
|
||||
message: message.into(),
|
||||
remediation:
|
||||
"Adjust the prompt or parameters and retry. Use list_image_models to confirm the model's constraints."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reference_image(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::ReferenceImageFailed,
|
||||
message: message.into(),
|
||||
remediation:
|
||||
"Reference images must be publicly-reachable https:// URLs returning image/png, image/jpeg, or image/gif (max 20 MB). Check the URL and retry."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_code: ErrorCode::Internal,
|
||||
message: message.into(),
|
||||
remediation:
|
||||
"If this keeps happening, restart Claude Desktop and inspect ~/Library/Logs/Claude/mcp-server-artcraft.log for details."
|
||||
.to_string(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem signals that the desktop app has been installed and at
|
||||
/// least launched once. We treat either condition as "installed".
|
||||
pub fn is_artcraft_app_installed() -> bool {
|
||||
Path::new("/Applications/ArtCraft.app").exists() || cookie_jar_dir_exists()
|
||||
}
|
||||
|
||||
fn cookie_jar_dir_exists() -> bool {
|
||||
directories::ProjectDirs::from("ai", "artcraft", "app")
|
||||
.map(|d| d.cache_dir().exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
|
||||
/// Fetch an image URL and return (base64 data, mime type) ready to hand
|
||||
/// to rmcp's `Content::image`. Used to inline-render the generated image
|
||||
/// in chat instead of a "show image" link.
|
||||
pub async fn fetch_image_for_inline(url: &str) -> Result<(String, String)> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url).send().await?.error_for_status()?;
|
||||
|
||||
let mime = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||
.unwrap_or_else(|| guess_mime_from_url(url).to_string());
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
let data_b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
Ok((data_b64, mime))
|
||||
}
|
||||
|
||||
fn guess_mime_from_url(url: &str) -> &'static str {
|
||||
let lower = url.split('?').next().unwrap_or(url).to_ascii_lowercase();
|
||||
if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if lower.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else if lower.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else {
|
||||
"image/png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
mod creds;
|
||||
mod errors;
|
||||
mod inline_image;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
use anyhow::Result;
|
||||
use rmcp::transport::stdio;
|
||||
use rmcp::ServiceExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::server::ArtcraftServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
if std::env::args().any(|a| a == "--check" || a == "-c") {
|
||||
return run_check_mode().await;
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_ansi(false)
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_env("ARTCRAFT_MCP_LOG").unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let service = ArtcraftServer::new().serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Diagnostic mode (`artcraft-mcp --check`). Prints a human-readable
|
||||
/// status report to stdout — handy for users who don't see the Artcraft
|
||||
/// tools in Claude and want to figure out why without round-tripping
|
||||
/// through the MCP transport.
|
||||
async fn run_check_mode() -> Result<()> {
|
||||
let report = tools::check_artcraft_connection::run().await;
|
||||
match report {
|
||||
Ok(value) => {
|
||||
println!("artcraft-mcp check:");
|
||||
println!("{}", serde_json::to_string_pretty(&value).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
let body = serde_json::to_string_pretty(&err).unwrap_or_else(|_| format!("{:?}", err));
|
||||
println!("artcraft-mcp check FAILED:");
|
||||
println!("{}", body);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::{
|
||||
CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::{tool, tool_handler, tool_router};
|
||||
use rmcp::{ErrorData as McpError, ServerHandler};
|
||||
|
||||
use crate::errors::ToolError;
|
||||
use crate::inline_image::fetch_image_for_inline;
|
||||
use crate::tools::{
|
||||
check_artcraft_connection, estimate_image_cost, generate_image, get_user_status,
|
||||
list_image_models,
|
||||
};
|
||||
|
||||
/// Inline preview thumbnail width. Claude Desktop caps tool results at
|
||||
/// ~1 MB; base64 adds ~33% overhead, so the total raw payload needs to
|
||||
/// stay under ~700 KB. We scale the per-image width down for batches
|
||||
/// so all images fit in the same response.
|
||||
const INLINE_THUMBNAIL_WIDTH_SINGLE: u32 = 768;
|
||||
const INLINE_THUMBNAIL_WIDTH_BATCH: u32 = 512;
|
||||
|
||||
const SERVER_INSTRUCTIONS: &str = "Artcraft MCP server.
|
||||
|
||||
Suggested workflow:
|
||||
(1) list_image_models — see available models and per-model constraints.
|
||||
(2) get_user_status — check credits / plan when the user asks.
|
||||
(3) estimate_image_cost — preview cost before charging the user.
|
||||
(4) generate_image — create the image.
|
||||
|
||||
Error handling: every failed tool call returns a JSON body with
|
||||
`error_code`, `message`, and `remediation`. Surface the remediation to
|
||||
the user. Common codes:
|
||||
|
||||
- not_logged_in / artcraft_not_installed: tell the user to open
|
||||
/Applications/ArtCraft.app and sign in (or download from
|
||||
https://getartcraft.com if not installed).
|
||||
- session_expired: tell the user to open the desktop app and sign in
|
||||
again.
|
||||
- backend_unavailable: usually transient; suggest retrying in a minute.
|
||||
- invalid_params: re-check list_image_models and retry with corrected
|
||||
parameters.
|
||||
- generation_timeout: the job may still be running; check the desktop
|
||||
app's task queue.
|
||||
|
||||
If you suspect the MCP connection itself is misbehaving (no tools
|
||||
returning, weird transport errors), call check_artcraft_connection.
|
||||
It always succeeds and reports desktop_app_installed, signed_in,
|
||||
session_valid, plus any remediation.";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ArtcraftServer {
|
||||
tool_router: ToolRouter<ArtcraftServer>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ArtcraftServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Diagnostic health check for the Artcraft MCP connection. Always succeeds. Returns { status: \"ok\" | \"not_installed\" | \"not_logged_in\" | \"session_invalid\" | \"error\", desktop_app_installed, signed_in, session_valid, error_code, remediation }. Call this when troubleshooting or when the user asks if the integration is working."
|
||||
)]
|
||||
async fn check_artcraft_connection(&self) -> Result<CallToolResult, McpError> {
|
||||
match check_artcraft_connection::run().await {
|
||||
Ok(value) => Ok(json_success(&value)),
|
||||
Err(err) => Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "List all image models available through Artcraft along with per-model constraints (aspect_ratio_options, resolution_options, quality_options, batch_size_max, image_refs_supported, image_refs_max, text_prompt_max_length, etc.). Call this BEFORE generate_image so you can pick valid parameters. `provider` is optional; defaults to \"artcraft\". Pass \"all\" to see every known model."
|
||||
)]
|
||||
async fn list_image_models(
|
||||
&self,
|
||||
Parameters(args): Parameters<list_image_models::Args>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
Ok(json_or_error(list_image_models::run(args).await))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Get the signed-in user's current credit balance and active subscription plan. No arguments. Returns { credits: { free, monthly, banked, total }, subscription: { product_slug, next_bill_at, subscription_end_at, ... } | null }."
|
||||
)]
|
||||
async fn get_user_status(&self) -> Result<CallToolResult, McpError> {
|
||||
Ok(json_or_error(get_user_status::run().await))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Pre-flight cost estimate for an image generation. Takes the same model/aspect_ratio/resolution/quality/num_images you'd pass to generate_image, plus num_reference_images (0 for text-to-image, >0 for image-edit). Returns cost_in_credits, cost_in_usd_cents, is_free, is_unlimited, is_rate_limited, has_watermark. Use this to confirm cost with the user before generating."
|
||||
)]
|
||||
async fn estimate_image_cost(
|
||||
&self,
|
||||
Parameters(args): Parameters<estimate_image_cost::Args>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
Ok(json_or_error(estimate_image_cost::run(args).await))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Generate an image with Artcraft. Required: `prompt`. Optional: `model` (snake_case id — call list_image_models for valid values; defaults to nano_banana_pro), `aspect_ratio`, `resolution`, `quality`, `num_images`, `reference_image_urls` (https URLs for image-edit / img2img — only valid on models with image_refs_supported=true). Each parameter must satisfy the constraints reported by list_image_models for the chosen model. Blocks up to 90 seconds for completion. Returns the generated images inline, plus their full-resolution CDN URLs and the realized cost (credits charged + remaining balance). The cost is derived by diffing the credit balance before and after generation."
|
||||
)]
|
||||
async fn generate_image(
|
||||
&self,
|
||||
Parameters(args): Parameters<generate_image::Args>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match generate_image::run(args).await {
|
||||
Ok(images) => Ok(success_images_result(images).await),
|
||||
Err(err) => Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for ArtcraftServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::from_build_env())
|
||||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||||
.with_instructions(SERVER_INSTRUCTIONS.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn success_images_result(images: generate_image::GeneratedImages) -> CallToolResult {
|
||||
let width = if images.images.len() > 1 {
|
||||
INLINE_THUMBNAIL_WIDTH_BATCH
|
||||
} else {
|
||||
INLINE_THUMBNAIL_WIDTH_SINGLE
|
||||
};
|
||||
let width_str = width.to_string();
|
||||
|
||||
let mut content: Vec<Content> = Vec::with_capacity(images.images.len() * 2 + 1);
|
||||
for image in &images.images {
|
||||
let inline_url = image
|
||||
.maybe_thumbnail_template
|
||||
.as_ref()
|
||||
.map(|t| t.replace("{WIDTH}", &width_str))
|
||||
.unwrap_or_else(|| image.cdn_url.clone());
|
||||
|
||||
match fetch_image_for_inline(&inline_url).await {
|
||||
Ok((data_b64, mime)) => content.push(Content::image(data_b64, mime)),
|
||||
Err(err) => {
|
||||
tracing::warn!("inline fetch failed for {}: {:#}", image.cdn_url, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let urls_text = if images.images.len() == 1 {
|
||||
images.images[0].cdn_url.clone()
|
||||
} else {
|
||||
images
|
||||
.images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| format!("Image {}: {}", i + 1, img.cdn_url))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let text = match &images.cost {
|
||||
Some(cost) => format!(
|
||||
"{}\n\nCost: {} credits charged. Balance after: {} credits.",
|
||||
urls_text, cost.credits_charged, cost.credits_balance_after
|
||||
),
|
||||
None => urls_text,
|
||||
};
|
||||
content.push(Content::text(text));
|
||||
|
||||
CallToolResult::success(content)
|
||||
}
|
||||
|
||||
fn json_or_error(result: Result<serde_json::Value, ToolError>) -> CallToolResult {
|
||||
match result {
|
||||
Ok(value) => json_success(&value),
|
||||
Err(err) => error_result(&err),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_success(value: &serde_json::Value) -> CallToolResult {
|
||||
let body = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
|
||||
CallToolResult::success(vec![Content::text(body)])
|
||||
}
|
||||
|
||||
fn error_result(err: &ToolError) -> CallToolResult {
|
||||
let body = serde_json::to_string_pretty(err)
|
||||
.unwrap_or_else(|_| format!("{{\"message\": \"{}\"}}", err.message.replace('"', "\\\"")));
|
||||
CallToolResult::error(vec![Content::text(body)])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
|
||||
use crate::creds::load_session;
|
||||
use crate::errors::{is_artcraft_app_installed, ToolError};
|
||||
|
||||
/// Health check that always succeeds (returns Ok) and reports a
|
||||
/// structured diagnostic. Safe to call any time — useful when Claude
|
||||
/// suspects something is off or when the user explicitly asks
|
||||
/// "is the connection working?".
|
||||
pub async fn run() -> Result<Value, ToolError> {
|
||||
let app_installed = is_artcraft_app_installed();
|
||||
|
||||
match load_session() {
|
||||
Err(err) => Ok(json!({
|
||||
"status": status_from_error_code(&err.error_code),
|
||||
"desktop_app_installed": app_installed,
|
||||
"signed_in": false,
|
||||
"session_valid": null,
|
||||
"error_code": err.error_code,
|
||||
"remediation": err.remediation,
|
||||
})),
|
||||
Ok((api_host, creds)) => {
|
||||
// Cheap authenticated call. If it succeeds the session is good;
|
||||
// a failure here usually means the cookie expired since sign-in.
|
||||
match get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft).await {
|
||||
Ok(_) => Ok(json!({
|
||||
"status": "ok",
|
||||
"desktop_app_installed": app_installed,
|
||||
"signed_in": true,
|
||||
"session_valid": true,
|
||||
"error_code": null,
|
||||
"remediation": null,
|
||||
})),
|
||||
Err(api_err) => {
|
||||
let expired = ToolError::session_expired();
|
||||
Ok(json!({
|
||||
"status": "session_invalid",
|
||||
"desktop_app_installed": app_installed,
|
||||
"signed_in": true,
|
||||
"session_valid": false,
|
||||
"error_code": expired.error_code,
|
||||
"remediation": expired.remediation,
|
||||
"details": { "api_error": format!("{:?}", api_err) },
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_from_error_code(code: &crate::errors::ErrorCode) -> &'static str {
|
||||
use crate::errors::ErrorCode::*;
|
||||
match code {
|
||||
ArtcraftNotInstalled => "not_installed",
|
||||
NotLoggedIn => "not_logged_in",
|
||||
SessionExpired => "session_invalid",
|
||||
_ => "error",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use artcraft_api_defs::generate::cost_estimate::estimate_image_cost::{
|
||||
EstimateImageCostRequest, GenerationMode,
|
||||
};
|
||||
use artcraft_client::endpoints::generate::cost_estimate::image::estimate_image_cost::estimate_image_cost;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
|
||||
use crate::creds::load_session;
|
||||
use crate::errors::ToolError;
|
||||
|
||||
#[derive(Debug, Deserialize, rmcp::schemars::JsonSchema)]
|
||||
pub struct Args {
|
||||
/// Model id (snake_case). Call list_image_models for valid ids.
|
||||
pub model: String,
|
||||
|
||||
/// Number of reference images. 0 (default) → text_to_image; >0 →
|
||||
/// image_edit with that many input images.
|
||||
#[serde(default)]
|
||||
pub num_reference_images: Option<u32>,
|
||||
|
||||
/// Optional aspect ratio (snake_case). See model's aspect_ratio_options.
|
||||
#[serde(default)]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Optional resolution (snake_case). See model's resolution_options.
|
||||
#[serde(default)]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Optional quality preset (snake_case). See model's quality_options.
|
||||
#[serde(default)]
|
||||
pub quality: Option<String>,
|
||||
|
||||
/// Number of images per generation. Defaults to 1.
|
||||
#[serde(default)]
|
||||
pub num_images: Option<u16>,
|
||||
}
|
||||
|
||||
pub async fn run(args: Args) -> Result<Value, ToolError> {
|
||||
let (api_host, creds) = load_session()?;
|
||||
|
||||
let model = parse_enum::<CommonImageModel>("model", &args.model)?;
|
||||
let aspect_ratio = parse_optional_enum::<CommonAspectRatio>("aspect_ratio", args.aspect_ratio.as_deref())?;
|
||||
let resolution = parse_optional_enum::<CommonResolution>("resolution", args.resolution.as_deref())?;
|
||||
let quality = parse_optional_enum::<CommonQuality>("quality", args.quality.as_deref())?;
|
||||
|
||||
let generation_mode = match args.num_reference_images.unwrap_or(0) {
|
||||
0 => GenerationMode::TextToImage,
|
||||
n => GenerationMode::ImageEdit { count: n },
|
||||
};
|
||||
|
||||
let request = EstimateImageCostRequest {
|
||||
model,
|
||||
provider: GenerationProvider::Artcraft,
|
||||
generation_mode,
|
||||
aspect_ratio,
|
||||
resolution,
|
||||
quality,
|
||||
image_batch_count: args.num_images,
|
||||
};
|
||||
|
||||
let response = estimate_image_cost(&api_host, Some(&creds), request)
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("cost estimate failed: {:?}", e)))?;
|
||||
|
||||
Ok(json!({
|
||||
"cost_in_credits": response.cost_in_credits,
|
||||
"cost_in_usd_cents": response.cost_in_usd_cents,
|
||||
"is_free": response.is_free,
|
||||
"is_unlimited": response.is_unlimited,
|
||||
"is_rate_limited": response.is_rate_limited,
|
||||
"has_watermark": response.has_watermark,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_enum<T: serde::de::DeserializeOwned>(field: &str, raw: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_value::<T>(serde_json::Value::String(raw.to_string())).map_err(|_| {
|
||||
ToolError::invalid_params(format!(
|
||||
"invalid {}: {}. Call list_image_models for valid values.",
|
||||
field, raw
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_enum<T: serde::de::DeserializeOwned>(
|
||||
field: &str,
|
||||
raw: Option<&str>,
|
||||
) -> Result<Option<T>, ToolError> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(s) => parse_enum::<T>(field, s).map(Some),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use rmcp::schemars;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::{sleep, Instant};
|
||||
|
||||
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
|
||||
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
|
||||
use artcraft_client::endpoints::media_files::list_batch_generated_redux_media_files::list_batch_generated_redux_media_files;
|
||||
use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{
|
||||
upload_image_media_file_from_bytes, ImageType, UploadImageBytesArgs,
|
||||
};
|
||||
use artcraft_client::endpoints::omni_gen::generate::image::omni_gen_image::omni_gen_image_generate;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
|
||||
use enums::common::generation::common_image_model::CommonImageModel;
|
||||
use enums::common::generation::common_quality::CommonQuality;
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::job_status_plus::JobStatusPlus;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::creds::load_session;
|
||||
use crate::errors::ToolError;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
const POLL_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const DEFAULT_MODEL: CommonImageModel = CommonImageModel::NanoBananaPro;
|
||||
const MAX_REFERENCE_IMAGE_BYTES: usize = 20 * 1024 * 1024;
|
||||
const REFERENCE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct GeneratedImage {
|
||||
pub cdn_url: String,
|
||||
pub maybe_thumbnail_template: Option<String>,
|
||||
}
|
||||
|
||||
pub struct GeneratedImages {
|
||||
pub images: Vec<GeneratedImage>,
|
||||
/// None if either credit lookup failed; otherwise the realized cost
|
||||
/// of this generation derived by diffing the user's credit balance
|
||||
/// from just-before-submit to just-after-success.
|
||||
pub cost: Option<CostInfo>,
|
||||
}
|
||||
|
||||
pub struct CostInfo {
|
||||
pub credits_charged: u64,
|
||||
pub credits_balance_after: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct Args {
|
||||
/// Text prompt describing the image to generate.
|
||||
pub prompt: String,
|
||||
|
||||
/// Optional model id (snake_case). Call list_image_models for valid
|
||||
/// ids and per-model constraints. Defaults to nano_banana_pro.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Optional aspect ratio (snake_case). Must appear in the chosen
|
||||
/// model's aspect_ratio_options. See list_image_models.
|
||||
#[serde(default)]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Optional resolution (snake_case). Must appear in the chosen
|
||||
/// model's resolution_options. See list_image_models.
|
||||
#[serde(default)]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Optional quality preset (snake_case). Must appear in the model's
|
||||
/// quality_options. See list_image_models.
|
||||
#[serde(default)]
|
||||
pub quality: Option<String>,
|
||||
|
||||
/// Number of images to generate in this batch. Defaults to 1.
|
||||
/// Hard cap is per-model (batch_size_max from list_image_models).
|
||||
#[serde(default)]
|
||||
pub num_images: Option<u16>,
|
||||
|
||||
/// Optional https:// URLs of reference images for image-edit (img2img)
|
||||
/// generation. Each is fetched, validated as image/*, uploaded as a
|
||||
/// media file, and passed to the model. Only valid when the chosen
|
||||
/// model has image_refs_supported=true. Max 20 MB per image.
|
||||
#[serde(default)]
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn run(args: Args) -> Result<GeneratedImages, ToolError> {
|
||||
let prompt = args.prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err(ToolError::invalid_params("prompt is empty"));
|
||||
}
|
||||
|
||||
let model = resolve_model(args.model.as_deref())?;
|
||||
let aspect_ratio =
|
||||
parse_optional_enum::<CommonAspectRatio>("aspect_ratio", args.aspect_ratio.as_deref())?;
|
||||
let resolution =
|
||||
parse_optional_enum::<CommonResolution>("resolution", args.resolution.as_deref())?;
|
||||
let quality = parse_optional_enum::<CommonQuality>("quality", args.quality.as_deref())?;
|
||||
|
||||
let (api_host, creds) = load_session()?;
|
||||
|
||||
let image_media_tokens = match args.reference_image_urls.as_deref() {
|
||||
None => None,
|
||||
Some(urls) if urls.is_empty() => None,
|
||||
Some(urls) => Some(upload_reference_images(urls, &api_host, &creds).await?),
|
||||
};
|
||||
|
||||
let request = OmniGenImageCostAndGenerateRequest {
|
||||
idempotency_token: Some(uuid::Uuid::new_v4().hyphenated().to_string()),
|
||||
model: Some(model),
|
||||
prompt: Some(prompt.to_string()),
|
||||
image_media_tokens,
|
||||
resolution,
|
||||
aspect_ratio,
|
||||
quality,
|
||||
image_batch_count: args.num_images.or(Some(1)),
|
||||
adjust_horizontal_angle: None,
|
||||
adjust_vertical_angle: None,
|
||||
adjust_zoom: None,
|
||||
};
|
||||
|
||||
// Snapshot credits before submit so we can compute the realized cost
|
||||
// by diffing against the balance after success. Best-effort — if the
|
||||
// credits API hiccups we still complete the generation, we just don't
|
||||
// report a cost.
|
||||
let credits_before = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft)
|
||||
.await
|
||||
.map(|c| c.sum_total_credits)
|
||||
.map_err(|e| tracing::warn!("pre-submit credits lookup failed: {:?}", e))
|
||||
.ok();
|
||||
|
||||
tracing::info!("submitting omni_gen image request, model={:?}", model);
|
||||
|
||||
let submit = omni_gen_image_generate(&api_host, Some(&creds), request)
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("submit failed: {:?}", e)))?;
|
||||
|
||||
if !submit.success {
|
||||
return Err(ToolError::backend("submit returned success=false"));
|
||||
}
|
||||
|
||||
tracing::info!("submitted; inference_job_token={:?}", submit.inference_job_token);
|
||||
|
||||
let mut result = poll_for_image_url(&api_host, &creds, &submit.inference_job_token).await?;
|
||||
|
||||
// Compute realized cost. If concurrent generations on other tools
|
||||
// settle in this window the diff can over-count, so we annotate
|
||||
// the value in the log; the LLM gets the number either way.
|
||||
let credits_after = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft)
|
||||
.await
|
||||
.map(|c| c.sum_total_credits)
|
||||
.map_err(|e| {
|
||||
tracing::warn!("post-success credits lookup failed: {:?}", e);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let (Some(before), Some(after)) = (credits_before, credits_after) {
|
||||
let charged = before.saturating_sub(after);
|
||||
tracing::info!(
|
||||
"realized cost: {} credits (balance {} -> {})",
|
||||
charged,
|
||||
before,
|
||||
after
|
||||
);
|
||||
result.cost = Some(CostInfo {
|
||||
credits_charged: charged,
|
||||
credits_balance_after: after,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn poll_for_image_url(
|
||||
api_host: &ApiHost,
|
||||
creds: &StorytellerCredentialSet,
|
||||
target: &InferenceJobToken,
|
||||
) -> Result<GeneratedImages, ToolError> {
|
||||
let deadline = Instant::now() + POLL_TIMEOUT;
|
||||
|
||||
let mut include_states = HashSet::new();
|
||||
include_states.insert(JobStatusPlus::CompleteSuccess);
|
||||
include_states.insert(JobStatusPlus::CompleteFailure);
|
||||
include_states.insert(JobStatusPlus::Dead);
|
||||
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(ToolError::generation_timeout(format!("{:?}", target)));
|
||||
}
|
||||
|
||||
let response = list_session_jobs(
|
||||
api_host,
|
||||
Some(creds),
|
||||
States::Include(include_states.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("poll failed: {:?}", e)))?;
|
||||
|
||||
if let Some(job) = response.jobs.iter().find(|j| &j.job_token == target) {
|
||||
match job.status.status {
|
||||
JobStatusPlus::CompleteSuccess => {
|
||||
let result = job.maybe_result.as_ref().ok_or_else(|| {
|
||||
ToolError::backend("job marked complete but result missing")
|
||||
})?;
|
||||
|
||||
// Batched generation returns one media file per result, all
|
||||
// tied together by maybe_batch_token. The single result on
|
||||
// the job is just one of them — fetch the rest via the batch
|
||||
// endpoint.
|
||||
if let Some(batch_token) = &result.maybe_batch_token {
|
||||
let batch = list_batch_generated_redux_media_files(
|
||||
api_host,
|
||||
Some(creds),
|
||||
batch_token,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("batch fetch failed: {:?}", e)))?;
|
||||
|
||||
let images: Vec<GeneratedImage> = batch
|
||||
.media_files
|
||||
.iter()
|
||||
.map(|m| GeneratedImage {
|
||||
cdn_url: m.media_links.cdn_url.to_string(),
|
||||
maybe_thumbnail_template: m.media_links.maybe_thumbnail_template.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!("batch fetch returned {} images", images.len());
|
||||
|
||||
// Defensive: backend could in theory return zero items.
|
||||
if images.is_empty() {
|
||||
return Ok(GeneratedImages {
|
||||
images: vec![GeneratedImage {
|
||||
cdn_url: result.media_links.cdn_url.to_string(),
|
||||
maybe_thumbnail_template: result
|
||||
.media_links
|
||||
.maybe_thumbnail_template
|
||||
.clone(),
|
||||
}],
|
||||
cost: None,
|
||||
});
|
||||
}
|
||||
return Ok(GeneratedImages { images, cost: None });
|
||||
}
|
||||
|
||||
return Ok(GeneratedImages {
|
||||
images: vec![GeneratedImage {
|
||||
cdn_url: result.media_links.cdn_url.to_string(),
|
||||
maybe_thumbnail_template: result.media_links.maybe_thumbnail_template.clone(),
|
||||
}],
|
||||
cost: None,
|
||||
});
|
||||
}
|
||||
JobStatusPlus::CompleteFailure | JobStatusPlus::Dead => {
|
||||
let msg = job
|
||||
.status
|
||||
.maybe_failure_message
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{:?}", job.status.status));
|
||||
return Err(ToolError::generation_failed(msg));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_reference_images(
|
||||
urls: &[String],
|
||||
api_host: &ApiHost,
|
||||
creds: &StorytellerCredentialSet,
|
||||
) -> Result<Vec<MediaFileToken>, ToolError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REFERENCE_FETCH_TIMEOUT)
|
||||
.gzip(true)
|
||||
.build()
|
||||
.map_err(|e| ToolError::internal(format!("client build failed: {:?}", e)))?;
|
||||
|
||||
let mut tokens = Vec::with_capacity(urls.len());
|
||||
for url in urls {
|
||||
let token = upload_one_reference(url, &client, api_host, creds).await?;
|
||||
tokens.push(token);
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
async fn upload_one_reference(
|
||||
url: &str,
|
||||
client: &reqwest::Client,
|
||||
api_host: &ApiHost,
|
||||
creds: &StorytellerCredentialSet,
|
||||
) -> Result<MediaFileToken, ToolError> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(ToolError::reference_image(format!(
|
||||
"URL must use https://: {}",
|
||||
url
|
||||
)));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::reference_image(format!("fetch failed for {}: {:?}", url, e)))?
|
||||
.error_for_status()
|
||||
.map_err(|e| ToolError::reference_image(format!("HTTP error for {}: {:?}", url, e)))?;
|
||||
|
||||
let mime_raw = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(';').next().unwrap_or(s).trim().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let image_type = match mime_raw.as_str() {
|
||||
"image/png" => ImageType::Png,
|
||||
"image/jpeg" | "image/jpg" => ImageType::Jpeg,
|
||||
"image/gif" => ImageType::Gif,
|
||||
other => {
|
||||
return Err(ToolError::reference_image(format!(
|
||||
"unsupported content-type {:?} for {}",
|
||||
other, url
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ToolError::reference_image(format!("read body failed for {}: {:?}", url, e)))?;
|
||||
if bytes.len() > MAX_REFERENCE_IMAGE_BYTES {
|
||||
return Err(ToolError::reference_image(format!(
|
||||
"reference image is {} bytes (cap {}): {}",
|
||||
bytes.len(),
|
||||
MAX_REFERENCE_IMAGE_BYTES,
|
||||
url
|
||||
)));
|
||||
}
|
||||
|
||||
tracing::info!("uploading reference image: {} bytes from {}", bytes.len(), url);
|
||||
|
||||
let upload = upload_image_media_file_from_bytes(UploadImageBytesArgs {
|
||||
api_host,
|
||||
maybe_creds: Some(creds),
|
||||
image_bytes: bytes.to_vec(),
|
||||
image_type,
|
||||
is_intermediate_system_file: true,
|
||||
maybe_generation_provider: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("media upload failed: {:?}", e)))?;
|
||||
|
||||
if !upload.success {
|
||||
return Err(ToolError::backend("media upload returned success=false"));
|
||||
}
|
||||
Ok(upload.media_file_token)
|
||||
}
|
||||
|
||||
fn resolve_model(raw: Option<&str>) -> Result<CommonImageModel, ToolError> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(DEFAULT_MODEL),
|
||||
Some(s) => parse_enum::<CommonImageModel>("model", s),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_enum<T: serde::de::DeserializeOwned>(
|
||||
field: &str,
|
||||
raw: Option<&str>,
|
||||
) -> Result<Option<T>, ToolError> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(s) => parse_enum::<T>(field, s).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_enum<T: serde::de::DeserializeOwned>(field: &str, raw: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_value::<T>(serde_json::Value::String(raw.to_string())).map_err(|_| {
|
||||
ToolError::invalid_params(format!(
|
||||
"invalid {}: {}. Call list_image_models for valid values.",
|
||||
field, raw
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde_json::{json, Value};
|
||||
use tokio::try_join;
|
||||
|
||||
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
|
||||
use artcraft_client::endpoints::subscriptions::get_session_subscription::get_session_subscription;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
|
||||
use crate::creds::load_session;
|
||||
use crate::errors::ToolError;
|
||||
|
||||
pub async fn run() -> Result<Value, ToolError> {
|
||||
let (api_host, creds) = load_session()?;
|
||||
|
||||
let credits_fut = get_session_credits(&api_host, Some(&creds), PaymentsNamespace::Artcraft);
|
||||
let subscription_fut =
|
||||
get_session_subscription(&api_host, Some(&creds), PaymentsNamespace::Artcraft);
|
||||
|
||||
let (credits, subscription) = try_join!(credits_fut, subscription_fut)
|
||||
.map_err(|e| ToolError::backend(format!("user status fetch failed: {:?}", e)))?;
|
||||
|
||||
let subscription_value = serde_json::to_value(&subscription.active_subscription)
|
||||
.map_err(|e| ToolError::internal(format!("failed to serialize subscription: {:?}", e)))?;
|
||||
|
||||
Ok(json!({
|
||||
"credits": {
|
||||
"free": credits.free_credits,
|
||||
"monthly": credits.monthly_credits,
|
||||
"banked": credits.banked_credits,
|
||||
"total": credits.sum_total_credits,
|
||||
},
|
||||
"subscription": subscription_value,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use artcraft_client::utils::api_host::ApiHost;
|
||||
|
||||
use crate::creds::load_session;
|
||||
use crate::errors::ToolError;
|
||||
|
||||
const PATH: &str = "/v1/omni_gen/models/image";
|
||||
|
||||
#[derive(Debug, Deserialize, rmcp::schemars::JsonSchema)]
|
||||
pub struct Args {
|
||||
/// Provider filter. "artcraft" (default) lists only models reachable
|
||||
/// via the Artcraft account. "all" lists every known model across
|
||||
/// all providers — most won't be callable without per-provider creds.
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(args: Args) -> Result<Value, ToolError> {
|
||||
let (api_host, creds) = load_session()?;
|
||||
|
||||
let path = match args
|
||||
.provider
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
Some(p) => format!("{}?provider={}", PATH, p),
|
||||
None => PATH.to_string(),
|
||||
};
|
||||
|
||||
// artcraft_client's basic_json_get_request is pub(crate) so we can't
|
||||
// reuse it from outside the crate — do a direct reqwest GET with the
|
||||
// same cookie-header convention.
|
||||
get_json(&api_host, &path, &creds).await
|
||||
}
|
||||
|
||||
async fn get_json(
|
||||
api_host: &ApiHost,
|
||||
path: &str,
|
||||
creds: &StorytellerCredentialSet,
|
||||
) -> Result<Value, ToolError> {
|
||||
let url = format!("{}{}", api_host.to_api_hostname_and_scheme(), path);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.gzip(true)
|
||||
.build()
|
||||
.map_err(|e| ToolError::internal(format!("client build failed: {:?}", e)))?;
|
||||
|
||||
let mut request = client.get(&url).header("Accept", "application/json");
|
||||
if let Some(cookie) = creds.maybe_as_cookie_header() {
|
||||
request = request.header("Cookie", cookie);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("request to {} failed: {:?}", url, e)))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(ToolError::session_expired());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::backend(format!(
|
||||
"HTTP {} from {}",
|
||||
status.as_u16(),
|
||||
url
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::backend(format!("reading response body failed: {:?}", e)))?;
|
||||
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::backend(format!("response was not JSON: {:?}", e)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod check_artcraft_connection;
|
||||
pub mod estimate_image_cost;
|
||||
pub mod generate_image;
|
||||
pub mod get_user_status;
|
||||
pub mod list_image_models;
|
||||
Reference in New Issue
Block a user