diff --git a/config.example.toml b/config.example.toml index e6596cf..552ea4a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -43,6 +43,9 @@ mcp_direct_limit = 15 # Currency label for the cost statistics (prices below are in this currency). currency = "USD" +# Largest file upload in MB. Uploads are sent in 8 MB chunks, so the reverse +# proxy's body limit (e.g. Nginx client_max_body_size) does not apply. +max_upload_mb = 500 # ----------------------------------------------------------------------------- diff --git a/server/Cargo.lock b/server/Cargo.lock index 9d1fb9f..7ecf8dc 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -167,6 +202,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -324,6 +365,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -343,6 +395,7 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" name = "codecraft" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "argon2", "async-trait", @@ -354,10 +407,12 @@ dependencies = [ "eventsource-stream", "futures", "globset", + "hkdf", "html2text", "ignore", "image", "libc", + "p256", "rand 0.10.3", "regex", "reqwest", @@ -434,6 +489,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -486,6 +547,22 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -502,7 +579,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", ] [[package]] @@ -512,6 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -526,6 +615,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -565,6 +665,43 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.44" @@ -633,6 +770,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -818,6 +965,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "globset" version = "0.4.21" @@ -831,6 +987,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -849,6 +1016,24 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html2text" version = "0.15.5" @@ -929,7 +1114,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -1171,6 +1358,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1479,6 +1675,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1512,6 +1721,15 @@ dependencies = [ "phc", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1573,6 +1791,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1592,6 +1820,17 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1616,6 +1855,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1874,6 +2140,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + [[package]] name = "ring" version = "0.17.14" @@ -2038,6 +2314,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2142,6 +2432,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2195,6 +2495,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2251,6 +2561,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.6.2" @@ -2697,6 +3017,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3073,6 +3403,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/server/Cargo.toml b/server/Cargo.toml index b0796b2..c630438 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -38,3 +38,6 @@ tower-http = { version = "0.7", features = ["fs", "cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "v7"] } +p256 = { version = "0.14", features = ["ecdh", "ecdsa"] } +hkdf = "0.13" +aes-gcm = "0.11" diff --git a/server/resources/commands/recreate-start.md b/server/resources/commands/recreate-start.md index da26989..44ed3c6 100644 --- a/server/resources/commands/recreate-start.md +++ b/server/resources/commands/recreate-start.md @@ -5,6 +5,6 @@ clean_room: false --- Load the skill "recreate" with the skill tool and carry out phases 1–4 for the software in original/ (inventory and unpacking, analysis, documentation and specification, proposal). -Track the phases with todo_write. Stop after the proposal and ask the user how to proceed. +Report the workflow phase with craft_phase (study while taking inventory and analysing, specify while documenting and writing the proposal) and track the steps inside a phase with todo_write. Stop after the proposal and ask the user how to proceed. Additional notes from the user: $ARGUMENTS diff --git a/server/resources/skills/recreate/SKILL.md b/server/resources/skills/recreate/SKILL.md index 92d0108..454d1c3 100644 --- a/server/resources/skills/recreate/SKILL.md +++ b/server/resources/skills/recreate/SKILL.md @@ -88,5 +88,5 @@ ask_user how to proceed. Do not start building before the user agrees. code are new. Facts needed for compatibility (file formats, constants, rules) are fine. - Original assets (graphics, sounds, levels) stay the user's files: the re-creation may load them from `original/` at runtime but does not copy them into the repository. -- Use todo_write for the phases; delegate broad searches through decompiled output to +- Report phases with craft_phase and use todo_write for the steps; delegate broad searches through decompiled output to explore subagents to keep your own context small. diff --git a/server/src/api.rs b/server/src/api.rs index 0a2019f..1c42520 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router { .route("/sessions", get(list_sessions).post(create_session)) .route("/sessions/search", get(search_sessions)) .route("/sessions/{id}/fork", post(fork_session)) + .route("/sessions/{id}/title", post(suggest_title)) .route("/sessions/{id}/export", get(export_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}/prompt", post(prompt)) @@ -120,6 +121,9 @@ pub fn router(app: AppState) -> Router { .route("/images/config", get(images_config)) .route("/images/{id}", delete(delete_image)) .route("/images/{id}/file", get(image_file)) + .route("/push/key", get(push_key)) + .route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe)) + .route("/push/test", post(push_test)) .route("/ws", get(ws)) .layer(middleware::from_fn_with_state(app.clone(), auth::require_user)) // Prompts may carry images (base64); the default limit is 2 MB. @@ -163,12 +167,13 @@ async fn get_config(Extension(user): Extension, State(app): State, agent: Option, permission_mode: Option, + clean_room: Option, } /// Resolve a directory and check it lies inside one of the user's roots. @@ -364,7 +370,8 @@ async fn create_session(Extension(user): Extension, State(app): State, State(app): State, +} + +/// Generate a title suggestion with a chosen model (not saved). +async fn suggest_title(Extension(user): Extension, State(app): State, Path(id): Path, body: Option>) -> ApiResult { + writable(&app, &user, &id)?; + let model = body.and_then(|b| b.0.model); + let title = crate::runner::suggest_title(app.clone(), &id, model).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?; + Ok(Json(json!({"title": title}))) +} + #[derive(Deserialize, Default)] struct ForkBody { /// Last message to keep (inclusive); all messages when missing. @@ -414,6 +434,7 @@ async fn fork_session(Extension(user): Extension, State(app): State, State(app): State, Qu // --- websocket -------------------------------------------------------------- +// --- web push ------------------------------------------------------------------- + +async fn push_key(State(app): State) -> ApiResult { + Ok(Json(json!({"key": app.push.public_key()}))) +} + +#[derive(Deserialize)] +struct PushKeys { + p256dh: String, + auth: String, +} + +#[derive(Deserialize)] +struct PushSubscription { + endpoint: String, + keys: PushKeys, +} + +async fn push_subscribe(Extension(user): Extension, State(app): State, Json(sub): Json) -> ApiResult { + if !sub.endpoint.starts_with("https://") { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid push endpoint".into())); + } + app.store.push_add(&user.id, &sub.endpoint, &sub.keys.p256dh, &sub.keys.auth)?; + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +struct PushEndpoint { + endpoint: String, +} + +async fn push_unsubscribe(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + // Only the owner's own subscription. + if app.store.push_subscriptions(&user.id)?.iter().any(|s| s.endpoint == b.endpoint) { + app.store.push_remove(&b.endpoint)?; + } + Ok(Json(json!({"ok": true}))) +} + +async fn push_test(Extension(user): Extension, State(app): State) -> ApiResult { + let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await; + Ok(Json(json!({"sent": sent}))) +} + async fn ws(Extension(user): Extension, State(app): State, upgrade: WebSocketUpgrade) -> Response { upgrade.on_upgrade(move |socket| ws_loop(app, user, socket)) } @@ -1002,6 +1067,7 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) { let mut ping = tokio::time::interval(std::time::Duration::from_secs(25)); // Re-check the login regularly so disabled users lose their live connection. let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60)); + let mut visible = false; loop { tokio::select! { ev = rx.recv() => match ev { @@ -1021,6 +1087,18 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) { }, msg = socket.recv() => match msg { Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break, + Some(Ok(WsMessage::Text(t))) => { + // The client reports whether its window is visible (push only when none is). + if let Ok(v) = serde_json::from_str::(&t) { + if v["type"] == "visibility" { + let now = v["visible"].as_bool().unwrap_or(false); + if now != visible { + app.push.set_visible(&user.id, if now { 1 } else { -1 }); + visible = now; + } + } + } + } _ => {} }, _ = ping.tick() => { @@ -1035,6 +1113,9 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) { } } } + if visible { + app.push.set_visible(&user.id, -1); + } } async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> { diff --git a/server/src/app.rs b/server/src/app.rs index 4df72a8..306e43d 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -71,6 +71,8 @@ pub struct App { pub shells: crate::shells::Shells, /// Language servers (diagnostics after edits). pub lsp: crate::lsp::Lsp, + /// Web Push (VAPID key, window visibility per user). + pub push: crate::push::Push, } /// What a user may do with a session. @@ -108,6 +110,7 @@ impl App { tool_install: Mutex::default(), shells: Mutex::default(), lsp: Default::default(), + push: crate::push::Push::new(&data), }); crate::auth::bootstrap(&app)?; app.clone().start_mcp(); diff --git a/server/src/config.rs b/server/src/config.rs index f28bd26..f003230 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -246,6 +246,8 @@ pub struct ServerConfig { pub budget_warning: f64, /// "warn" (default) or "block": refuse new model requests once a budget is used up. pub budget_action: String, + /// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply). + pub max_upload_mb: u64, } impl Default for ServerConfig { @@ -263,6 +265,7 @@ impl Default for ServerConfig { monthly_budget: 0.0, budget_warning: 0.8, budget_action: "warn".into(), + max_upload_mb: 500, } } } diff --git a/server/src/files.rs b/server/src/files.rs index 26f3c77..df47fa4 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -373,16 +373,35 @@ pub async fn remove(Extension(user): Extension, State(app): State, } -/// Upload one file as the raw request body. +/// Upload a file as the raw request body – whole, or in chunks with `offset` +/// (large files: each chunk stays below the reverse proxy's body limit). pub async fn upload(Extension(user): Extension, State(app): State, Query(q): Query, body: Bytes) -> ApiResult { let p = checked_path(&app, &user, &q.path, false)?; if p.is_dir() { return Err(bad("is a directory")); } - tokio::fs::write(&p, &body).await?; - Ok(Json(json!({"ok": true, "path": p, "size": body.len()}))) + let max = app.config().server.max_upload_mb.saturating_mul(1024 * 1024); + let offset = q.offset.unwrap_or(0); + if offset + body.len() as u64 > max { + return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("file is larger than {} MB", max / 1024 / 1024))); + } + if offset == 0 { + tokio::fs::write(&p, &body).await?; + } else { + use tokio::io::AsyncWriteExt; + let current = tokio::fs::metadata(&p).await.map(|m| m.len()).unwrap_or(0); + if current != offset { + return Err(ApiError(StatusCode::CONFLICT, format!("chunk offset {offset} does not match the uploaded size {current}"))); + } + let mut f = tokio::fs::OpenOptions::new().append(true).open(&p).await?; + f.write_all(&body).await?; + f.flush().await?; + } + Ok(Json(json!({"ok": true, "path": p, "size": offset + body.len() as u64}))) } #[derive(Deserialize)] diff --git a/server/src/main.rs b/server/src/main.rs index 742c0c8..6965de8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -16,6 +16,7 @@ mod mentions; mod mcp; mod permission; mod prompts; +mod push; mod provider; mod reverse; mod runner; @@ -47,6 +48,7 @@ async fn main() -> Result<()> { let bind = cfg.server.bind.clone(); let app = app::App::new(path, cfg).await?; lsp::spawn_reaper(app.clone()); + push::spawn_listener(app.clone()); let router = api::router(app); let listener = tokio::net::TcpListener::bind(&bind).await?; tracing::info!("listening on http://{bind}"); diff --git a/server/src/prompts.rs b/server/src/prompts.rs index b541e09..7596cfa 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -50,7 +50,7 @@ const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify an const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits. Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#; -const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase). +const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase. 1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly. 2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code. diff --git a/server/src/push.rs b/server/src/push.rs new file mode 100644 index 0000000..8e1785b --- /dev/null +++ b/server/src/push.rs @@ -0,0 +1,303 @@ +//! Web Push notifications (VAPID, RFC 8030/8291/8292) without external services. +//! +//! The server sends a push to a user's subscribed devices when an agent asks +//! for permission, asks a question, or finishes a longer run – but only while +//! none of the user's app windows is visible. + +use crate::app::App; +use crate::types::Event; +use aes_gcm::aead::{Aead, KeyInit}; +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use hkdf::Hkdf; +use p256::ecdsa::signature::Signer; +use p256::elliptic_curve::sec1::ToSec1Point; +use p256::{PublicKey, SecretKey}; +use serde_json::{Value, json}; +use sha2::Sha256; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Runs shorter than this don't trigger a "finished" notification. +const MIN_RUN_FOR_DONE: Duration = Duration::from_secs(20); + +pub struct Push { + key_path: PathBuf, + key: OnceLock, + /// user id -> number of visible app windows (reported over the WebSocket) + visible: Mutex>, + /// session id -> start of the current run + runs: Mutex>, +} + +fn random_bytes() -> [u8; N] { + rand::random() +} + +impl Push { + pub fn new(data_dir: &std::path::Path) -> Self { + Self { key_path: data_dir.join("vapid.key"), key: OnceLock::new(), visible: Mutex::default(), runs: Mutex::default() } + } + + /// The VAPID key pair, created on first use and kept on the data volume. + fn key(&self) -> &SecretKey { + self.key.get_or_init(|| { + if let Ok(bytes) = std::fs::read(&self.key_path) { + if let Ok(k) = SecretKey::from_slice(&bytes) { + return k; + } + } + let k = loop { + if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) { + break k; + } + }; + let _ = std::fs::write(&self.key_path, k.to_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600)); + } + k + }) + } + + /// Public key for PushManager.subscribe (base64url, uncompressed point). + pub fn public_key(&self) -> String { + B64.encode(self.key().public_key().as_affine().to_uncompressed_point()) + } + + pub fn set_visible(&self, user_id: &str, delta: isize) { + let mut v = self.visible.lock().unwrap(); + let n = v.entry(user_id.to_string()).or_default(); + *n = (*n as isize + delta).max(0) as usize; + } + + fn is_visible(&self, user_id: &str) -> bool { + self.visible.lock().unwrap().get(user_id).is_some_and(|n| *n > 0) + } +} + +/// RFC 8291 "aes128gcm" encryption of one record. +fn encrypt(ua_public: &[u8], auth: &[u8], plaintext: &[u8]) -> Result> { + let as_secret = loop { + if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) { + break k; + } + }; + encrypt_with(ua_public, auth, plaintext, &as_secret, random_bytes::<16>()) +} + +fn encrypt_with(ua_public: &[u8], auth: &[u8], plaintext: &[u8], as_secret: &SecretKey, salt: [u8; 16]) -> Result> { + let ua = PublicKey::from_sec1_bytes(ua_public).map_err(|_| anyhow!("invalid p256dh key"))?; + let as_public = as_secret.public_key().as_affine().to_uncompressed_point(); + let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua.as_affine()); + + // IKM from the ECDH secret and the subscription's auth secret. + let mut key_info = b"WebPush: info\0".to_vec(); + key_info.extend_from_slice(ua_public); + key_info.extend_from_slice(&as_public); + let mut ikm = [0u8; 32]; + Hkdf::::new(Some(auth), shared.raw_secret_bytes()).expand(&key_info, &mut ikm).map_err(|_| anyhow!("hkdf"))?; + + let prk = Hkdf::::new(Some(&salt), &ikm); + let mut cek = [0u8; 16]; + let mut nonce = [0u8; 12]; + prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).map_err(|_| anyhow!("hkdf"))?; + prk.expand(b"Content-Encoding: nonce\0", &mut nonce).map_err(|_| anyhow!("hkdf"))?; + + let mut record = plaintext.to_vec(); + record.push(2); // padding delimiter: last record + let cipher = aes_gcm::Aes128Gcm::new_from_slice(&cek).map_err(|_| anyhow!("aes key"))?; + let ct = cipher.encrypt(&nonce.into(), record.as_slice()).map_err(|_| anyhow!("encryption failed"))?; + + let mut out = Vec::with_capacity(16 + 4 + 1 + 65 + ct.len()); + out.extend_from_slice(&salt); + out.extend_from_slice(&4096u32.to_be_bytes()); + out.push(as_public.len() as u8); + out.extend_from_slice(&as_public); + out.extend_from_slice(&ct); + Ok(out) +} + +/// VAPID authorization header (RFC 8292) for an endpoint. +fn vapid_header(key: &SecretKey, endpoint: &str, subject: &str) -> Result { + let url = reqwest::Url::parse(endpoint)?; + let aud = url.origin().ascii_serialization(); + let exp = chrono::Utc::now().timestamp() + 12 * 3600; + let header = B64.encode(br#"{"typ":"JWT","alg":"ES256"}"#); + let claims = B64.encode(serde_json::to_vec(&json!({"aud": aud, "exp": exp, "sub": subject}))?); + let signing_input = format!("{header}.{claims}"); + let sk = p256::ecdsa::SigningKey::from(key); + let sig: p256::ecdsa::Signature = sk.sign(signing_input.as_bytes()); + let jwt = format!("{signing_input}.{}", B64.encode(sig.to_bytes())); + let k = B64.encode(key.public_key().as_affine().to_uncompressed_point()); + Ok(format!("vapid t={jwt}, k={k}")) +} + +/// Send a notification to all devices of a user. +pub async fn send_to_user(app: &App, user_id: &str, payload: &Value) -> usize { + let subs = app.store.push_subscriptions(user_id).unwrap_or_default(); + let mut sent = 0; + for sub in subs { + match send_one(app, &sub.endpoint, &sub.p256dh, &sub.auth, payload).await { + Ok(()) => sent += 1, + Err(e) => { + let gone = e.to_string().contains("410") || e.to_string().contains("404"); + tracing::debug!("push to {}: {e:#}", sub.endpoint); + if gone { + let _ = app.store.push_remove(&sub.endpoint); + } + } + } + } + sent +} + +async fn send_one(app: &App, endpoint: &str, p256dh: &str, auth: &str, payload: &Value) -> Result<()> { + let ua = B64.decode(p256dh.trim_end_matches('=')).context("p256dh")?; + let auth = B64.decode(auth.trim_end_matches('=')).context("auth")?; + let body = encrypt(&ua, &auth, &serde_json::to_vec(payload)?)?; + let cfg = app.config(); + let subject = if cfg.auth.public_url.starts_with("https://") { cfg.auth.public_url.clone() } else { "mailto:admin@localhost".to_string() }; + let resp = crate::provider::http_client() + .post(endpoint) + .header("Authorization", vapid_header(app.push.key(), endpoint, &subject)?) + .header("Content-Encoding", "aes128gcm") + .header("Content-Type", "application/octet-stream") + .header("TTL", "86400") + .header("Urgency", "high") + .timeout(Duration::from_secs(20)) + .body(body) + .send() + .await?; + if !resp.status().is_success() { + bail!("HTTP {}: {}", resp.status().as_u16(), resp.text().await.unwrap_or_default().chars().take(200).collect::()); + } + Ok(()) +} + +/// Owner and title of the top-level session. +fn session_info(app: &App, session_id: &str) -> Option<(String, String, String)> { + let root = app.root_session_id(session_id); + let s = app.store.get_session(&root).ok().flatten()?; + let title = if s.title.is_empty() { "CodeCraft".to_string() } else { s.title.clone() }; + Some((s.owner_id?, title, root)) +} + +/// Background task: turn agent events into notifications. +pub fn spawn_listener(app: Arc) { + tokio::spawn(async move { + let mut rx = app.subscribe(); + loop { + let ev = match rx.recv().await { + Ok(ev) => ev, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + }; + let (session_id, kind, body) = match &ev { + Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")), + Event::QuestionRequest { session_id, questions, .. } => { + let q = questions.first().map(|q| q.question.clone()).unwrap_or_default(); + (session_id.clone(), "question", q) + } + Event::SessionStatus { session_id, busy: true } => { + app.push.runs.lock().unwrap().insert(session_id.clone(), Instant::now()); + continue; + } + Event::SessionStatus { session_id, busy: false } => { + let started = app.push.runs.lock().unwrap().remove(session_id); + if !started.is_some_and(|t| t.elapsed() >= MIN_RUN_FOR_DONE) { + continue; + } + // Subagents report through their parent. + if app.store.get_session(session_id).ok().flatten().is_none_or(|s| s.parent_id.is_some()) { + continue; + } + (session_id.clone(), "done", String::new()) + } + _ => continue, + }; + let Some((owner, title, root)) = session_info(&app, &session_id) else { continue }; + if app.push.is_visible(&owner) { + continue; + } + let payload = json!({ + "kind": kind, + "title": title, + "body": body.chars().take(180).collect::(), + "tag": format!("{root}-{kind}"), + "url": format!("/#/s/{root}"), + }); + let app2 = app.clone(); + tokio::spawn(async move { + send_to_user(&app2, &owner, &payload).await; + }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Decrypt with the user agent's keys (RFC 8291) to check the encryption. + #[test] + fn roundtrip() { + let ua_secret = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let ua_public = ua_secret.public_key().as_affine().to_uncompressed_point(); + let auth = [9u8; 16]; + let msg = br#"{"title":"hi"}"#; + let body = encrypt(&ua_public, &auth, msg).unwrap(); + + let salt = &body[..16]; + let idlen = body[20] as usize; + let as_public = &body[21..21 + idlen]; + let ct = &body[21 + idlen..]; + let as_pk = PublicKey::from_sec1_bytes(as_public).unwrap(); + let shared = p256::ecdh::diffie_hellman(ua_secret.to_nonzero_scalar(), as_pk.as_affine()); + let mut info = b"WebPush: info\0".to_vec(); + info.extend_from_slice(&ua_public); + info.extend_from_slice(as_public); + let mut ikm = [0u8; 32]; + Hkdf::::new(Some(&auth), shared.raw_secret_bytes()).expand(&info, &mut ikm).unwrap(); + let prk = Hkdf::::new(Some(salt), &ikm); + let (mut cek, mut nonce) = ([0u8; 16], [0u8; 12]); + prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).unwrap(); + prk.expand(b"Content-Encoding: nonce\0", &mut nonce).unwrap(); + let pt = aes_gcm::Aes128Gcm::new_from_slice(&cek).unwrap().decrypt(&nonce.into(), ct).unwrap(); + assert_eq!(&pt[..pt.len() - 1], msg); + assert_eq!(pt[pt.len() - 1], 2); + } + + /// Example from RFC 8291, section 5. + #[test] + fn rfc8291_vector() { + let d = |s: &str| B64.decode(s).unwrap(); + let as_secret = SecretKey::from_slice(&d("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw")).unwrap(); + let ua_public = d("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"); + let auth = d("BTBZMqHH6r4Tts7J_aSIgg"); + let salt: [u8; 16] = d("DGv6ra1nlYgDCS1FRnbzlw").try_into().unwrap(); + let out = encrypt_with(&ua_public, &auth, b"When I grow up, I want to be a watermelon", &as_secret, salt).unwrap(); + assert_eq!( + B64.encode(out), + "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN" + ); + } + + #[test] + fn vapid_jwt_verifies() { + use p256::ecdsa::signature::Verifier; + let key = SecretKey::from_slice(&[3u8; 32]).unwrap(); + let h = vapid_header(&key, "https://fcm.googleapis.com/fcm/send/abc", "mailto:a@b.c").unwrap(); + let jwt = h.strip_prefix("vapid t=").unwrap().split(", k=").next().unwrap(); + let (input, sig) = jwt.rsplit_once('.').unwrap(); + let sig = p256::ecdsa::Signature::from_slice(&B64.decode(sig).unwrap()).unwrap(); + p256::ecdsa::VerifyingKey::from(key.public_key()).verify(input.as_bytes(), &sig).unwrap(); + let claims: Value = serde_json::from_slice(&B64.decode(input.split('.').nth(1).unwrap()).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://fcm.googleapis.com"); + } +} diff --git a/server/src/runner.rs b/server/src/runner.rs index 087c03a..bdd7c79 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -220,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us all.into_iter() .filter(|t| !agent.read_only || t.read_only()) // Craft tools only where they are useful. - .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some()) + .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some()) .filter(|t| match &agent.tools { None => true, Some(list) => { @@ -640,6 +640,7 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s todos: Value::Null, last_usage: None, clean_room: parent.clean_room, + phase: None, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); @@ -929,6 +930,45 @@ fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points."; +/// Suggest a title for a session from its conversation, with the given model +/// (or the small model). The title is returned, not saved. +pub async fn suggest_title(app: Arc, session_id: &str, model_spec: Option) -> Result { + let session = app.session(session_id)?; + let spec = model_spec.filter(|m| !m.is_empty()).unwrap_or_else(|| small_model(&app, &session)); + let (provider, mut model) = app.registry().resolve(&spec)?; + model.effort = Some("low".into()); + let history = active_history(app.store.messages(session_id)?); + let mut transcript = transcript_of(&history); + if transcript.len() > 12_000 { + // Beginning (the goal) and end (where it went). + let head = clip(&transcript, 6_000); + let mut start = transcript.len() - 5_000; + while !transcript.is_char_boundary(start) { + start += 1; + } + transcript = format!("{head}\n…\n{}", &transcript[start..]); + } + if transcript.trim().is_empty() { + bail!("the session has no messages yet"); + } + let req = ChatRequest { + system: "Write a short, specific title (max 6 words) for the coding session below. Use the language of the user's messages. Reply with the title only, no quotes, no punctuation at the end.".into(), + messages: vec![Message::new(Role::User, vec![Part::Text { text: format!("\n{transcript}\n") }])], + tools: vec![], + max_tokens: 2000, + reasoning: false, + model: model.clone(), + }; + let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?; + record_side_usage(&app, &session, &spec, &model, &usage, "title"); + let text = strip_think(&text); + let title = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ']).to_string(); + if title.is_empty() { + bail!("the model returned no title"); + } + Ok(title.chars().take(80).collect()) +} + /// Remove a leading `…` block some models put into the text. fn strip_think(text: &str) -> String { let t = text.trim_start(); @@ -953,7 +993,7 @@ fn clip(s: &str, max: usize) -> String { } /// The history as readable text for summarizing (tool output shortened). -fn transcript_of(history: &[Message]) -> String { +pub fn transcript_of(history: &[Message]) -> String { let mut out = String::new(); for m in history { let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" }; diff --git a/server/src/store.rs b/server/src/store.rs index d00282e..010749b 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -97,6 +97,13 @@ impl Store { created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at); + CREATE TABLE IF NOT EXISTS push_subs ( + endpoint TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, @@ -874,6 +881,30 @@ impl Store { // --- audit log & backup ---------------------------------------------------------- impl Store { + // --- web push --------------------------------------------------------- + + pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO push_subs (endpoint, user_id, p256dh, auth, created_at) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(endpoint) DO UPDATE SET user_id = ?2, p256dh = ?3, auth = ?4", + rusqlite::params![endpoint, user_id, p256dh, auth, now_ms()], + )?; + Ok(()) + } + + pub fn push_remove(&self, endpoint: &str) -> Result<()> { + self.conn.lock().unwrap().execute("DELETE FROM push_subs WHERE endpoint = ?1", [endpoint])?; + Ok(()) + } + + pub fn push_subscriptions(&self, user_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT endpoint, p256dh, auth FROM push_subs WHERE user_id = ?1")?; + let rows = stmt.query_map([user_id], |r| Ok(PushSub { endpoint: r.get(0)?, p256dh: r.get(1)?, auth: r.get(2)? }))?; + Ok(rows.collect::>()?) + } + pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) { let conn = self.conn.lock().unwrap(); let _ = conn.execute( @@ -946,3 +977,9 @@ impl Store { Ok(data.and_then(|d| serde_json::from_str::(&d).ok()).and_then(|v| v["created_at"].as_i64())) } } + +pub struct PushSub { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} diff --git a/server/src/tools/agent.rs b/server/src/tools/agent.rs index 48f5873..1ac9ed1 100644 --- a/server/src/tools/agent.rs +++ b/server/src/tools/agent.rs @@ -55,6 +55,48 @@ impl Tool for TodoWrite { // --------------------------------------------------------------------------- +pub const CRAFT_PHASES: &[&str] = &["study", "specify", "build", "check", "ready"]; + +/// Craft workflow: report the current phase (shown as a progress bar). +pub struct CraftPhase; + +#[async_trait] +impl Tool for CraftPhase { + fn name(&self) -> &str { + "craft_phase" + } + fn description(&self) -> String { + "Report the craft phase you are entering (study, specify, build, check, ready). The user sees it \ + as a progress bar. Call it at the start of every phase, also when you go back to an earlier one." + .into() + } + fn schema(&self) -> Value { + json!({"type": "object", "properties": { + "phase": {"type": "string", "enum": CRAFT_PHASES}, + "note": {"type": "string", "description": "One short sentence: what happens in this phase"} + }, "required": ["phase"]}) + } + fn read_only(&self) -> bool { + true + } + fn title(&self, input: &Value) -> String { + format!("Phase: {}", input["phase"].as_str().unwrap_or("?")) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let phase = arg_str(&input, "phase")?; + if !CRAFT_PHASES.contains(&phase) { + bail!("unknown phase '{phase}' – use one of {}", CRAFT_PHASES.join(", ")); + } + let root = ctx.app.root_session_id(&ctx.session_id); + let mut s = ctx.app.session(&root)?; + s.phase = Some(phase.to_string()); + ctx.app.save_session(s)?; + Ok(ToolOutput::text(format!("Phase: {phase}")).with_meta(json!({"phase": phase, "note": input["note"]}))) + } +} + +// --------------------------------------------------------------------------- + pub struct AskUser; #[async_trait] diff --git a/server/src/tools/mod.rs b/server/src/tools/mod.rs index 7aaf67b..1d20357 100644 --- a/server/src/tools/mod.rs +++ b/server/src/tools/mod.rs @@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec> { Arc::new(crate::shells::KillShell), Arc::new(web::WebFetch), Arc::new(web::WebSearch), + Arc::new(agent::CraftPhase), Arc::new(crate::lsp::Diagnostics), Arc::new(agent::TodoWrite), Arc::new(agent::AskUser), diff --git a/server/src/types.rs b/server/src/types.rs index ca2ae42..e2f0b29 100644 --- a/server/src/types.rs +++ b/server/src/types.rs @@ -127,6 +127,9 @@ pub struct Session { /// Craft mode: clean room (true) or deep (false); None = server default. #[serde(default, skip_serializing_if = "Option::is_none")] pub clean_room: Option, + /// Craft workflow phase reported by the agent (study, specify, build, check, ready). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, } impl Session { diff --git a/web/public/push-sw.js b/web/public/push-sw.js new file mode 100644 index 0000000..b448705 --- /dev/null +++ b/web/public/push-sw.js @@ -0,0 +1,36 @@ +// Web Push handling, imported into the generated service worker (workbox importScripts). +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { body: event.data ? event.data.text() : "" }; + } + const title = data.title || "CodeCraft"; + const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || ""; + event.waitUntil( + self.registration.showNotification(prefix + title, { + body: data.kind === "done" ? data.body || "Finished" : data.body || "", + tag: data.tag || "codecraft", + renotify: true, + icon: "/icon-192.png", + badge: "/icon-192.png", + data: { url: data.url || "/" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { + for (const client of list) { + if (new URL(client.url).origin === self.location.origin) { + return client.focus().then((c) => (c && "navigate" in c ? c.navigate(url) : c)); + } + } + return self.clients.openWindow(url); + }), + ); +}); diff --git a/web/src/api.ts b/web/src/api.ts index 4a886a5..3efd6bb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -62,6 +62,7 @@ export function connectEvents(handlers: { ws = new WebSocket(`${proto}://${location.host}/api/ws`); ws.onopen = () => { retry = 0; + reportVisibility(); handlers.onOpen(); }; ws.onmessage = (m) => { @@ -80,7 +81,12 @@ export function connectEvents(handlers: { }; open(); // Reconnect immediately when the app comes back to the foreground (mobile PWA). + // The server sends push notifications only while no app window is visible. + const reportVisibility = () => { + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "visibility", visible: document.visibilityState === "visible" })); + }; const onVisible = () => { + reportVisibility(); if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) { clearTimeout(timer); open(); @@ -94,3 +100,28 @@ export function connectEvents(handlers: { ws?.close(); }; } + +const CHUNK = 8 * 1024 * 1024; + +/** + * Upload a file in 8 MB chunks (stays below reverse proxy body limits; up to the + * server's max_upload_mb). `onProgress` gets the uploaded fraction 0..1. + */ +export async function uploadFile(path: string, file: Blob, onProgress?: (fraction: number) => void): Promise { + let offset = 0; + do { + const chunk = file.slice(offset, offset + CHUNK); + const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}&offset=${offset}`, { method: "PUT", body: chunk, credentials: "same-origin" }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + msg = (await res.json()).error ?? msg; + } catch { + if (res.status === 413) msg = "too large for the reverse proxy"; + } + throw new Error(msg); + } + offset += chunk.size; + onProgress?.(file.size ? offset / file.size : 1); + } while (offset < file.size); +} diff --git a/web/src/components/Activity.tsx b/web/src/components/Activity.tsx index b730c63..009b198 100644 --- a/web/src/components/Activity.tsx +++ b/web/src/components/Activity.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; import { useEffect, useState } from "react"; import { fmtCompact, useT } from "../i18n"; import { useStore, type Draft } from "../store"; @@ -143,8 +144,8 @@ export function ActivityLine({ {label} {step && ( - ({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""} - {fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`}) + ({fmtElapsed(elapsed)} · {step.estimated ? "~" : ""} + {fmtCompact(step.promptTokens)} {t("activity.prefill")} · {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`}) )} @@ -163,8 +164,8 @@ export function useTitleActivity() { const waiting = useStore((s) => s.permissions.length + s.questions.length); useEffect(() => { const base = "CodeCraft"; - if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`; - else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`; + if (waiting > 0) document.title = `(!) ${t("activity.titleInput")} – ${base}`; + else if (busyCount > 0) document.title = `(${busyCount}) ${t("activity.titleBusy", { n: busyCount })} – ${base}`; else document.title = base; }, [busyCount, waiting, t]); } diff --git a/web/src/components/Budget.tsx b/web/src/components/Budget.tsx index d4bae76..b7e23d5 100644 --- a/web/src/components/Budget.tsx +++ b/web/src/components/Budget.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; import { useFetch } from "../hooks"; import { fmtCost, fmtPercent, useT } from "../i18n"; @@ -30,7 +31,7 @@ function Bar({ share, warning, exceeded }: { share: number; warning: boolean; ex function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType }) { if (!r) return –; - if (r.error) return ⚠ {t("budget.remoteError")}; + if (r.error) return {t("budget.remoteError")}; const cur = r.currency ?? "USD"; if (r.spent_month !== undefined) return {t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}; if (r.remaining !== null && r.remaining !== undefined) return {t("budget.balance", { v: fmtCost(r.remaining, cur) })}; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 486128e..cd85151 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -1,8 +1,9 @@ +import { Icon } from "./Icon"; import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { del, get, patch, post } from "../api"; import { useModels } from "../hooks"; -import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n"; +import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { dismissError, loadSession, loadSessions, useStore } from "../store"; import type { Aggregate, ImageData, Message, Part, Session } from "../types"; @@ -10,6 +11,7 @@ import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity"; import { useReasoningMode } from "../prefs"; import { Composer } from "./Composer"; import { ShareDialog } from "./ShareDialog"; +import { RenameDialog } from "./RenameDialog"; import { QueueList } from "./QueueList"; import { BudgetChip } from "./Budget"; import { PermissionCard, QuestionCard } from "./Interaction"; @@ -47,7 +49,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) { {open && (
@@ -240,27 +242,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () <>
- {editingTitle ? ( - (setEditingTitle(false), update({ title: e.target.value }))} - onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} - style={{ width: "100%" }} - /> - ) : ( -

setEditingTitle(true)} title={t("chat.renameHint")}> - {session.parent_id && ( - - ↩ - - )} - {session.title || t("session.untitled")} -

- )} +

!readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}> + {session.parent_id && ( + + + + )} + {session.title || t("session.untitled")} + {!readOnly && ( + + )} +

{session.cwd}
{ctxShare !== undefined && ( @@ -271,8 +268,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () {stats && ( @@ -285,17 +282,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () <> {!session.parent_id && ( )} {!session.parent_id && ( )}
- 💾 +
)}
+ {session.agent === "craft" && !session.parent_id && }
)} @@ -417,7 +415,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{e.message}
))} @@ -438,11 +436,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () {!following && (
)}
+ {editingTitle && setEditingTitle(false)} />} {sharing && setSharing(false)} />} {rewinding && setRewinding(undefined)} />} {!readOnly && } @@ -500,3 +499,32 @@ function fmtK(n: number) { if (n >= 1000) return `${Math.round(n / 1000)}k`; return String(n); } + +/** Craft workflow progress: Study → (Specify) → Build → Check → Ready. */ +function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) { + const t = useT(); + const phases = deep ? ["study", "specify", "build", "check", "ready"] : ["study", "build", "check", "ready"]; + const cur = phase ? phases.indexOf(phase) : -1; + return ( +
+ {phases.map((p, i) => { + const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : ""; + return ( + + {i > 0 && } + + + {state === "done" && ( + + )} + + {t(`phase.${p}` as Key)} + + + ); + })} +
+ ); +} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 31b3f1a..2fa9ce4 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -1,4 +1,6 @@ +import { Icon } from "./Icon"; import { formatSize } from "./FileFigure"; +import { uploadFile } from "../api"; import { useEffect, useMemo, useRef, useState } from "react"; import { useAgents, useFetch } from "../hooks"; import { useT, type Key } from "../i18n"; @@ -20,7 +22,7 @@ const MAX_SIDE = 2000; /** Read an image and downscale it: models don't use more detail, and uploads stay small. */ -async function readImage(file: File): Promise { +export async function readImage(file: File): Promise { const bitmap = await createImageBitmap(file); const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height)); const canvas = document.createElement("canvas"); @@ -179,8 +181,9 @@ export function Composer({ await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) }); // Keep uploads out of git status without touching the project's .gitignore. await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" }); - const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}`, { method: "PUT", body: f, credentials: "same-origin" }); - if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`); + await uploadFile(path, f).catch((e) => { + throw new Error(`${f.name}: ${e.message}`); + }); setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]); } catch (e: any) { toast(e.message ?? String(e)); @@ -229,10 +232,10 @@ export function Composer({
{files.map((f) => (
- 📄 {f.name} + {f.name} {formatSize(f.size)}
))} @@ -245,7 +248,7 @@ export function Composer({
))} @@ -310,14 +313,14 @@ export function Composer({ onUpdate({ model: m })} title={t("composer.model")} />
{busy && ( )} e.target.files && upload(e.target.files)} /> )) @@ -286,7 +287,7 @@ export function Files({ onMenu }: { onMenu: () => void }) { <> {listing?.parent && ( )} @@ -298,11 +299,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
- ⋯ +
@@ -333,7 +334,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
{pane.title}
@@ -349,11 +350,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
{baseName(f.path)} - {dirty && ●} + {dirty && } {(isMd || isImage) && !f.preview && ( )} - ⤓ + {!f.binary && (

{t("nav.files")}

diff --git a/web/src/components/GitPanel.tsx b/web/src/components/GitPanel.tsx index da013d1..32340d1 100644 --- a/web/src/components/GitPanel.tsx +++ b/web/src/components/GitPanel.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; import { useEffect, useState } from "react"; import { post } from "../api"; import { fmtRelative, useT } from "../i18n"; @@ -104,17 +105,17 @@ export function GitPanel({ {f.path} {isStaged ? ( ) : ( <> {f.index !== "?" && ( )} {status.upstream && ( - ↑{status.ahead} ↓{status.behind} + {status.ahead} {status.behind} )} diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx new file mode 100644 index 0000000..ef6cd4d --- /dev/null +++ b/web/src/components/Icon.tsx @@ -0,0 +1,229 @@ +import type { ReactNode } from "react"; + +/** + * Line icons drawn with `currentColor`: they take the text colour of their + * context, so every theme colours them (no per-theme icon sets needed). + */ +const P: Record = { + menu: , + close: , + check: , + "x-mark": , + "arrow-up": , + "arrow-down": , + "arrow-left": , + "chevron-right": , + "chevron-down": , + share: ( + <> + + + + + + ), + fork: ( + <> + + + + + + ), + download: , + compact: , + trash: , + edit: , + undo: , + clip: , + file: ( + <> + + + + ), + "file-text": ( + <> + + + + ), + "file-doc": ( + <> + + + + ), + folder: , + "folder-up": ( + <> + + + + ), + folders: ( + <> + + + + ), + image: ( + <> + + + + + ), + music: ( + <> + + + + + ), + cube: ( + <> + + + + ), + external: , + refresh: , + lock: ( + <> + + + + ), + question: ( + <> + + + + ), + "radio-on": ( + <> + + + + ), + "radio-off": , + "box-on": ( + <> + + + + ), + "box-off": , + play: , + stop: , + warning: , + brain: ( + <> + + + + ), + eye: ( + <> + + + + ), + gear: ( + <> + + + + ), + robot: ( + <> + + + + ), + plug: , + shield: ( + <> + + + + ), + users: ( + <> + + + + ), + user: ( + <> + + + + ), + toolbox: ( + <> + + + + ), + scroll: , + terminal: ( + <> + + + + ), + logout: , + chart: , + wrench: , + sun: ( + <> + + + + ), + moon: , + "theme-auto": ( + <> + + + + ), + dot: , + plus: , + minus: , + upload: , + more: ( + <> + + + + + ), + copy: ( + <> + + + + ), + sparkle: , +}; + +export type IconName = keyof typeof P; + +export function Icon({ name, size, className, title }: { name: string; size?: number; className?: string; title?: string }) { + return ( + + {title && {title}} + {P[name] ?? P.dot} + + ); +} diff --git a/web/src/components/Images.tsx b/web/src/components/Images.tsx index 8a7128f..2de1f6c 100644 --- a/web/src/components/Images.tsx +++ b/web/src/components/Images.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; import { useEffect, useState } from "react"; import { del, post } from "../api"; import { useFetch } from "../hooks"; @@ -63,7 +64,7 @@ export function Images({ onMenu }: { onMenu: () => void }) { <>

{t("nav.images")}

@@ -141,16 +142,16 @@ export function Images({ onMenu }: { onMenu: () => void }) {
- ⤓ +
diff --git a/web/src/components/Interaction.tsx b/web/src/components/Interaction.tsx index 694c977..fdb8472 100644 --- a/web/src/components/Interaction.tsx +++ b/web/src/components/Interaction.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; import { useState } from "react"; import { post } from "../api"; import { useT } from "../i18n"; @@ -43,7 +44,7 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur return (

- 🔐 {t("permission.title")} {req.tool} + {t("permission.title")} {req.tool}

{req.title}
@@ -122,7 +123,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current return (
-

❓ {t("question.title")}

+

{t("question.title")}

{source}
{req.questions.map((q, qi) => ( @@ -143,7 +144,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current onClick={() => toggle(qi, o.label, !!q.multi_select)} onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)} > - {q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"} +
{o.label === OTHER ? ( {showCode && children}
diff --git a/web/src/components/ModelViewer.tsx b/web/src/components/ModelViewer.tsx index 934d14c..3fa891e 100644 --- a/web/src/components/ModelViewer.tsx +++ b/web/src/components/ModelViewer.tsx @@ -1,3 +1,4 @@ +import { Icon } from "./Icon"; // 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM). // Loaded on demand only when such a file is opened. import { useEffect, useRef, useState } from "react"; @@ -147,7 +148,7 @@ export function ModelViewer({ url, ext }: { url: string; ext: string }) { return (
-
{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}
+
{state.loading ? "…" : state.error ? <> {state.error} : state.info}
); } diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx index a10555a..5832430 100644 --- a/web/src/components/NewSession.tsx +++ b/web/src/components/NewSession.tsx @@ -1,7 +1,11 @@ -import { useEffect, useState } from "react"; -import { post } from "../api"; +import { Icon } from "./Icon"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { post, uploadFile } from "../api"; import { useAgents, useFetch } from "../hooks"; -import { useT } from "../i18n"; +import { useT, type Key } from "../i18n"; +import type { ImageData } from "../types"; +import { readImage } from "./Composer"; +import { formatSize } from "./FileFigure"; import { ModelSelect } from "./ModelSelect"; interface DirListing { @@ -14,124 +18,585 @@ interface DirListing { const LAST_DIR = "codecraft.lastDir"; +type Task = "build" | "plan" | "craft" | string; +type Target = "website" | "app" | "game" | "software" | "fresh"; + +// --- icons (SVG: identical on every system) ------------------------------------- + +const svg = (children: ReactNode) => ( + +); +const ICONS: Record = { + build: svg(), + plan: svg( + <> + + + , + ), + craft: svg( + <> + + + , + ), + agent: svg( + <> + + + , + ), + website: svg( + <> + + + , + ), + app: svg( + <> + + + , + ), + game: svg( + <> + + + , + ), + software: svg( + <> + + + , + ), + fresh: svg(), + clean: svg( + <> + + + , + ), + deep: svg( + <> + + + , + ), + folder: svg(), + git: svg( + <> + + + + + , + ), + upload: svg(), + clip: svg(), + start: svg(), +}; + +function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
+

+ {n} + {title} +

+ {children} +
+ ); +} + +const slug = (s: string) => + s + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/https?:\/\/(www\.)?/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .split("-") + .slice(0, 4) + .join("-") + .slice(0, 40); + +async function api(path: string, init: RequestInit) { + const r = await fetch(`/api${path}`, { credentials: "same-origin", ...init }); + if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); + return r; +} + +/** Guided start: what to do → (craft: what to recreate, clean room/deep) → describe → model & folder. */ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) { const t = useT(); - const [path, setPath] = useState(localStorage.getItem(LAST_DIR) ?? ""); const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? []; - const { data: listing, error: dirError } = useFetch(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`); const config = useFetch<{ default_model?: string }>("/config").data; + const [task, setTask] = useState("craft"); + const [target, setTarget] = useState("fresh"); + const [deep, setDeep] = useState(false); + const [url, setUrl] = useState(""); + const [text, setText] = useState(""); + const [images, setImages] = useState([]); + const [files, setFiles] = useState([]); const [model, setModel] = useState(""); - const [agent, setAgent] = useState("build"); - const [prompt, setPrompt] = useState(""); + // Folder: a new one (name) inside a root, or an existing one (picked). + const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new"); + const [repoUrl, setRepoUrl] = useState(""); + // Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks. + const [originals, setOriginals] = useState([]); + const [progress, setProgress] = useState>({}); + const originalInput = useRef(null); + const [folderName, setFolderName] = useState(""); + const [nameTouched, setNameTouched] = useState(false); + const [rootPath, setRootPath] = useState(""); + const [picked, setPicked] = useState(localStorage.getItem(LAST_DIR) ?? ""); const [error, setError] = useState(); - const [creating, setCreating] = useState(false); - const agents = useAgents(listing?.path).filter((a) => a.mode === "primary"); + const [creating, setCreating] = useState(); + const fileInput = useRef(null); + const agents = useAgents(undefined).filter((a) => a.mode === "primary"); + const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name)); useEffect(() => { if (!model && config?.default_model) setModel(config.default_model); }, [config, model]); - - // A remembered directory that vanished: fall back to the workspace root. useEffect(() => { - if (dirError && path) setPath(""); - }, [dirError, path]); + if (!rootPath && roots.length) setRootPath(roots[roots.length - 1].path); + }, [roots, rootPath]); + // Old software is analysed in deep mode; websites default to clean room. + useEffect(() => { + if (target === "software") setDeep(true); + else if (target === "website") setDeep(false); + }, [target]); + // Working on existing code usually happens in an existing folder. + useEffect(() => { + setFolderMode((m) => (m === "git" ? m : task === "craft" ? "new" : "existing")); + }, [task]); - const create = async () => { - if (!listing) return; - setCreating(true); - setError(undefined); + const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? ""; + const autoName = useMemo( + () => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"), + [text, url, target, task, folderMode, repoName], + ); + useEffect(() => { + if (!nameTouched) setFolderName(autoName); + }, [autoName, nameTouched]); + + const ideas: Key[] = + task !== "craft" + ? task === "plan" + ? ["wizard.idea.planFeature", "wizard.idea.planRefactor"] + : ["wizard.idea.buildTests", "wizard.idea.buildBug"] + : ({ + website: ["wizard.idea.siteLanding", "wizard.idea.siteShop"], + app: ["wizard.idea.appTodo", "wizard.idea.appNotes"], + game: ["wizard.idea.gameSnake", "wizard.idea.gameBreakout"], + software: ["wizard.idea.swDos", "wizard.idea.swTool"], + fresh: ["wizard.idea.freshBakery", "wizard.idea.freshSnake"], + } as Record)[target]; + + const addFiles = async (list: FileList | File[]) => { + const all = [...list]; + const imgs = all.filter((f) => f.type.startsWith("image/") && target !== "software"); try { - localStorage.setItem(LAST_DIR, listing.path); - const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent }); - if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() }); - onCreated(s.id); + const read = await Promise.all(imgs.map(readImage)); + setImages((cur) => [...cur, ...read]); } catch (e: any) { setError(e.message); - setCreating(false); + } + setFiles((cur) => [...cur, ...all.filter((f) => !imgs.includes(f))]); + }; + + const needsUrl = task === "craft" && target === "website"; + const isRecreate = task === "craft" && target === "software"; + const canStart = + !creating && + (text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) && + (folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim()))); + + /** The first message(s) for the agent. */ + const prompts = (uploaded: string[]): string[] => { + const desc = text.trim(); + const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : ""; + if (task !== "craft") return [desc + fileList]; + switch (target) { + case "website": + return [`Recreate the website ${url.trim()} as a new project in this folder.${desc ? `\n\n${desc}` : ""}${fileList}`]; + case "app": + case "game": + return [ + `Recreate this ${target === "app" ? "app" : "game"} as a new project in this folder${images.length ? " – screenshots are attached" : ""}.${desc ? `\n\n${desc}` : ""}${fileList}`, + ]; + case "software": + return [ + `/recreate-init The program files are already in original/ (${uploaded.length ? uploaded.join(", ") : "added by the user"}). Set up the layout around them without moving them.`, + `/recreate-start ${desc}`, + ]; + default: + return [`Build this from scratch (there is no original to study – start with a short plan, then build, check and refine):\n\n${desc}${fileList}`]; } }; - const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : ""; - const currentRoot = listing?.root; + const create = async () => { + setError(undefined); + try { + // 1. Folder + let cwd = picked; + if (folderMode === "git") { + setCreating(t("wizard.cloning")); + const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`; + await post("/git", { action: "clone", path: target, url: repoUrl.trim() }); + cwd = target; + } else if (folderMode === "new") { + setCreating(t("wizard.creatingFolder")); + const base = folderName.trim().replace(/[\\/]/g, "-"); + for (let i = 0; i < 50; i++) { + const candidate = `${rootPath}/${i ? `${base}-${i + 1}` : base}`; + try { + await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: candidate, dir: true }) }); + cwd = candidate; + break; + } catch (e: any) { + if (!/exists/i.test(e.message)) throw e; + } + } + if (!cwd) throw new Error("could not create the folder"); + } + localStorage.setItem(LAST_DIR, cwd); + // 2. Files: originals of old software into original/, everything else into .codecraft/uploads/ + const uploaded: string[] = []; + const mkdirs = async (dir: string) => { + let parent = cwd; + for (const part of dir.split("/")) { + parent = `${parent}/${part}`; + await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: parent, dir: true }) }).catch(() => undefined); + } + }; + const send = async (list: File[], dir: string) => { + if (!list.length) return; + await mkdirs(dir); + for (const f of list) { + const name = f.name.replace(/[\\/]/g, "_"); + setCreating(`${t("wizard.uploading")} ${name}`); + await uploadFile(`${cwd}/${dir}/${name}`, f, (x) => setProgress((p) => ({ ...p, [f.name]: x }))); + uploaded.push(`${dir}/${name}`); + } + }; + // Originals of old software go to original/, everything else to .codecraft/uploads/. + await send(originals, "original"); + await send(files, isRecreate ? "original" : ".codecraft/uploads"); + // 3. Session and first prompt(s) + setCreating(t("wizard.starting")); + const isCraft = task === "craft"; + const s = await post<{ id: string }>("/sessions", { + cwd, + model: model || undefined, + agent: task, + clean_room: isCraft ? !deep : undefined, + }); + const msgs = prompts(uploaded).filter((m) => m.trim()); + for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] }); + onCreated(s.id); + } catch (e: any) { + setError(e.message); + setCreating(undefined); + } + }; + let n = 1; return ( <>

{t("new.title")}

-
-
-

{t("new.directory")}

- {roots.length > 1 && ( -
- {roots.map((r) => ( - + )} +
+ ))} +
+ )} + + )} + + + {needsUrl && ( + setUrl(e.target.value)} + /> + )} +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + void addFiles(e.dataTransfer.files); + }} + > +