Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2699
-203
No files matched your search
@@ -43,6 +43,9 @@ mcp_direct_limit = 15
|
|||||||
|
|
||||||
# Currency label for the cost statistics (prices below are in this currency).
|
# Currency label for the cost statistics (prices below are in this currency).
|
||||||
currency = "USD"
|
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
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+342
@@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
@@ -167,6 +202,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -324,6 +365,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -343,6 +395,7 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
|||||||
name = "codecraft"
|
name = "codecraft"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -354,10 +407,12 @@ dependencies = [
|
|||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
"globset",
|
"globset",
|
||||||
|
"hkdf",
|
||||||
"html2text",
|
"html2text",
|
||||||
"ignore",
|
"ignore",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
|
"p256",
|
||||||
"rand 0.10.3",
|
"rand 0.10.3",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -434,6 +489,12 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -486,6 +547,22 @@ version = "0.8.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -502,7 +579,18 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
"hybrid-array",
|
"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]]
|
[[package]]
|
||||||
@@ -512,6 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -526,6 +615,17 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
|
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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -565,6 +665,43 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.44"
|
version = "0.8.44"
|
||||||
@@ -633,6 +770,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.15"
|
version = "0.1.15"
|
||||||
@@ -818,6 +965,15 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||||
|
dependencies = [
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "globset"
|
name = "globset"
|
||||||
version = "0.4.21"
|
version = "0.4.21"
|
||||||
@@ -831,6 +987,17 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -849,6 +1016,24 @@ dependencies = [
|
|||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "html2text"
|
name = "html2text"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -929,7 +1114,9 @@ version = "0.4.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"subtle",
|
||||||
"typenum",
|
"typenum",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1171,6 +1358,15 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.2"
|
version = "2.12.2"
|
||||||
@@ -1479,6 +1675,19 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
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]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1512,6 +1721,15 @@ dependencies = [
|
|||||||
"phc",
|
"phc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem-rfc7468"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -1573,6 +1791,16 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -1592,6 +1820,17 @@ dependencies = [
|
|||||||
"miniz_oxide 0.8.9",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -1616,6 +1855,33 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -1874,6 +2140,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2038,6 +2314,20 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -2142,6 +2432,16 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serdect"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -2195,6 +2495,16 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -2251,6 +2561,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "sqlite-wasm-rs"
|
name = "sqlite-wasm-rs"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2697,6 +3017,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3073,6 +3403,18 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@@ -38,3 +38,6 @@ tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
uuid = { version = "1", features = ["v4", "v7"] }
|
uuid = { version = "1", features = ["v4", "v7"] }
|
||||||
|
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
|
||||||
|
hkdf = "0.13"
|
||||||
|
aes-gcm = "0.11"
|
||||||
@@ -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
|
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).
|
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
|
Additional notes from the user: $ARGUMENTS
|
||||||
@@ -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.
|
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
|
- 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.
|
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.
|
explore subagents to keep your own context small.
|
||||||
+83
-2
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions", get(list_sessions).post(create_session))
|
.route("/sessions", get(list_sessions).post(create_session))
|
||||||
.route("/sessions/search", get(search_sessions))
|
.route("/sessions/search", get(search_sessions))
|
||||||
.route("/sessions/{id}/fork", post(fork_session))
|
.route("/sessions/{id}/fork", post(fork_session))
|
||||||
|
.route("/sessions/{id}/title", post(suggest_title))
|
||||||
.route("/sessions/{id}/export", get(export_session))
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
@@ -120,6 +121,9 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/images/config", get(images_config))
|
.route("/images/config", get(images_config))
|
||||||
.route("/images/{id}", delete(delete_image))
|
.route("/images/{id}", delete(delete_image))
|
||||||
.route("/images/{id}/file", get(image_file))
|
.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))
|
.route("/ws", get(ws))
|
||||||
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
|
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
|
||||||
// Prompts may carry images (base64); the default limit is 2 MB.
|
// Prompts may carry images (base64); the default limit is 2 MB.
|
||||||
@@ -163,12 +167,13 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||||
if !user.is_admin() {
|
if !user.is_admin() {
|
||||||
// Users only need defaults, not the configuration.
|
// Users only need defaults, not the configuration.
|
||||||
return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency})));
|
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
|
||||||
}
|
}
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"config": config::redacted_json(&cfg),
|
"config": config::redacted_json(&cfg),
|
||||||
"workspace": app.workspace(),
|
"workspace": app.workspace(),
|
||||||
"default_model": default_model,
|
"default_model": default_model,
|
||||||
|
"small_model": cfg.server.small_model,
|
||||||
"currency": cfg.server.currency,
|
"currency": cfg.server.currency,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -320,6 +325,7 @@ struct CreateSession {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
|
clean_room: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a directory and check it lies inside one of the user's roots.
|
/// 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<User>, State(app): State<AppS
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
todos: Value::Null,
|
todos: Value::Null,
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
clean_room: None,
|
clean_room: body.clean_room,
|
||||||
|
phase: None,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -389,6 +396,19 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
|
|||||||
Ok(Json(json!(hits)))
|
Ok(Json(json!(hits)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct TitleBody {
|
||||||
|
model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a title suggestion with a chosen model (not saved).
|
||||||
|
async fn suggest_title(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<TitleBody>>) -> 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)]
|
#[derive(Deserialize, Default)]
|
||||||
struct ForkBody {
|
struct ForkBody {
|
||||||
/// Last message to keep (inclusive); all messages when missing.
|
/// Last message to keep (inclusive); all messages when missing.
|
||||||
@@ -414,6 +434,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
|
phase: None,
|
||||||
..src.clone()
|
..src.clone()
|
||||||
};
|
};
|
||||||
let fork = app.save_session(fork)?;
|
let fork = app.save_session(fork)?;
|
||||||
@@ -988,6 +1009,50 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
|
|||||||
|
|
||||||
// --- websocket --------------------------------------------------------------
|
// --- websocket --------------------------------------------------------------
|
||||||
|
|
||||||
|
// --- web push -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn push_key(State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(sub): Json<PushSubscription>) -> 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<User>, State(app): State<AppState>, Json(b): Json<PushEndpoint>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||||
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
|
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));
|
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
// Re-check the login regularly so disabled users lose their live connection.
|
// 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 recheck = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||||
|
let mut visible = false;
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
ev = rx.recv() => match ev {
|
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 {
|
msg = socket.recv() => match msg {
|
||||||
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
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::<Value>(&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() => {
|
_ = 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> {
|
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ pub struct App {
|
|||||||
pub shells: crate::shells::Shells,
|
pub shells: crate::shells::Shells,
|
||||||
/// Language servers (diagnostics after edits).
|
/// Language servers (diagnostics after edits).
|
||||||
pub lsp: crate::lsp::Lsp,
|
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.
|
/// What a user may do with a session.
|
||||||
@@ -108,6 +110,7 @@ impl App {
|
|||||||
tool_install: Mutex::default(),
|
tool_install: Mutex::default(),
|
||||||
shells: Mutex::default(),
|
shells: Mutex::default(),
|
||||||
lsp: Default::default(),
|
lsp: Default::default(),
|
||||||
|
push: crate::push::Push::new(&data),
|
||||||
});
|
});
|
||||||
crate::auth::bootstrap(&app)?;
|
crate::auth::bootstrap(&app)?;
|
||||||
app.clone().start_mcp();
|
app.clone().start_mcp();
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ pub struct ServerConfig {
|
|||||||
pub budget_warning: f64,
|
pub budget_warning: f64,
|
||||||
/// "warn" (default) or "block": refuse new model requests once a budget is used up.
|
/// "warn" (default) or "block": refuse new model requests once a budget is used up.
|
||||||
pub budget_action: String,
|
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 {
|
impl Default for ServerConfig {
|
||||||
@@ -263,6 +265,7 @@ impl Default for ServerConfig {
|
|||||||
monthly_budget: 0.0,
|
monthly_budget: 0.0,
|
||||||
budget_warning: 0.8,
|
budget_warning: 0.8,
|
||||||
budget_action: "warn".into(),
|
budget_action: "warn".into(),
|
||||||
|
max_upload_mb: 500,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-2
@@ -373,16 +373,35 @@ pub async fn remove(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
pub struct UploadQuery {
|
pub struct UploadQuery {
|
||||||
/// Target file path (absolute).
|
/// Target file path (absolute).
|
||||||
path: String,
|
path: String,
|
||||||
|
/// Chunked upload: byte offset of this chunk (0 = first chunk, creates the file).
|
||||||
|
offset: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
|
pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
|
||||||
let p = checked_path(&app, &user, &q.path, false)?;
|
let p = checked_path(&app, &user, &q.path, false)?;
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
return Err(bad("is a directory"));
|
return Err(bad("is a directory"));
|
||||||
}
|
}
|
||||||
|
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?;
|
tokio::fs::write(&p, &body).await?;
|
||||||
Ok(Json(json!({"ok": true, "path": p, "size": body.len()})))
|
} 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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod mentions;
|
|||||||
mod mcp;
|
mod mcp;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
|
mod push;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod reverse;
|
mod reverse;
|
||||||
mod runner;
|
mod runner;
|
||||||
@@ -47,6 +48,7 @@ async fn main() -> Result<()> {
|
|||||||
let bind = cfg.server.bind.clone();
|
let bind = cfg.server.bind.clone();
|
||||||
let app = app::App::new(path, cfg).await?;
|
let app = app::App::new(path, cfg).await?;
|
||||||
lsp::spawn_reaper(app.clone());
|
lsp::spawn_reaper(app.clone());
|
||||||
|
push::spawn_listener(app.clone());
|
||||||
let router = api::router(app);
|
let router = api::router(app);
|
||||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
tracing::info!("listening on http://{bind}");
|
tracing::info!("listening on http://{bind}");
|
||||||
|
|||||||
@@ -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.
|
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."#;
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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<SecretKey>,
|
||||||
|
/// user id -> number of visible app windows (reported over the WebSocket)
|
||||||
|
visible: Mutex<HashMap<String, usize>>,
|
||||||
|
/// session id -> start of the current run
|
||||||
|
runs: Mutex<HashMap<String, Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_bytes<const N: usize>() -> [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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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::<Sha256>::new(Some(auth), shared.raw_secret_bytes()).expand(&key_info, &mut ikm).map_err(|_| anyhow!("hkdf"))?;
|
||||||
|
|
||||||
|
let prk = Hkdf::<Sha256>::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<String> {
|
||||||
|
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::<String>());
|
||||||
|
}
|
||||||
|
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<App>) {
|
||||||
|
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::<String>(),
|
||||||
|
"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::<Sha256>::new(Some(&auth), shared.raw_secret_bytes()).expand(&info, &mut ikm).unwrap();
|
||||||
|
let prk = Hkdf::<Sha256>::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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-2
@@ -220,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|t| !agent.read_only || t.read_only())
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
// Craft tools only where they are useful.
|
// 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 {
|
.filter(|t| match &agent.tools {
|
||||||
None => true,
|
None => true,
|
||||||
Some(list) => {
|
Some(list) => {
|
||||||
@@ -640,6 +640,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
todos: Value::Null,
|
todos: Value::Null,
|
||||||
last_usage: None,
|
last_usage: None,
|
||||||
clean_room: parent.clean_room,
|
clean_room: parent.clean_room,
|
||||||
|
phase: None,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -929,6 +930,45 @@ fn maybe_generate_title(app: Arc<App>, 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.";
|
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<App>, session_id: &str, model_spec: Option<String>) -> Result<String> {
|
||||||
|
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!("<session>\n{transcript}\n</session>") }])],
|
||||||
|
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 `<think>…</think>` block some models put into the text.
|
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||||
fn strip_think(text: &str) -> String {
|
fn strip_think(text: &str) -> String {
|
||||||
let t = text.trim_start();
|
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).
|
/// 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();
|
let mut out = String::new();
|
||||||
for m in history {
|
for m in history {
|
||||||
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ impl Store {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS audit (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts INTEGER NOT NULL,
|
ts INTEGER NOT NULL,
|
||||||
@@ -874,6 +881,30 @@ impl Store {
|
|||||||
// --- audit log & backup ----------------------------------------------------------
|
// --- audit log & backup ----------------------------------------------------------
|
||||||
|
|
||||||
impl Store {
|
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<Vec<PushSub>> {
|
||||||
|
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::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
|
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
@@ -946,3 +977,9 @@ impl Store {
|
|||||||
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
|
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PushSub {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub p256dh: String,
|
||||||
|
pub auth: String,
|
||||||
|
}
|
||||||
@@ -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<ToolOutput> {
|
||||||
|
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;
|
pub struct AskUser;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
|||||||
Arc::new(crate::shells::KillShell),
|
Arc::new(crate::shells::KillShell),
|
||||||
Arc::new(web::WebFetch),
|
Arc::new(web::WebFetch),
|
||||||
Arc::new(web::WebSearch),
|
Arc::new(web::WebSearch),
|
||||||
|
Arc::new(agent::CraftPhase),
|
||||||
Arc::new(crate::lsp::Diagnostics),
|
Arc::new(crate::lsp::Diagnostics),
|
||||||
Arc::new(agent::TodoWrite),
|
Arc::new(agent::TodoWrite),
|
||||||
Arc::new(agent::AskUser),
|
Arc::new(agent::AskUser),
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ pub struct Session {
|
|||||||
/// Craft mode: clean room (true) or deep (false); None = server default.
|
/// Craft mode: clean room (true) or deep (false); None = server default.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub clean_room: Option<bool>,
|
pub clean_room: Option<bool>,
|
||||||
|
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -62,6 +62,7 @@ export function connectEvents(handlers: {
|
|||||||
ws = new WebSocket(`${proto}://${location.host}/api/ws`);
|
ws = new WebSocket(`${proto}://${location.host}/api/ws`);
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
retry = 0;
|
retry = 0;
|
||||||
|
reportVisibility();
|
||||||
handlers.onOpen();
|
handlers.onOpen();
|
||||||
};
|
};
|
||||||
ws.onmessage = (m) => {
|
ws.onmessage = (m) => {
|
||||||
@@ -80,7 +81,12 @@ export function connectEvents(handlers: {
|
|||||||
};
|
};
|
||||||
open();
|
open();
|
||||||
// Reconnect immediately when the app comes back to the foreground (mobile PWA).
|
// 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 = () => {
|
const onVisible = () => {
|
||||||
|
reportVisibility();
|
||||||
if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) {
|
if (document.visibilityState === "visible" && ws && ws.readyState > WebSocket.OPEN) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
open();
|
open();
|
||||||
@@ -94,3 +100,28 @@ export function connectEvents(handlers: {
|
|||||||
ws?.close();
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fmtCompact, useT } from "../i18n";
|
import { fmtCompact, useT } from "../i18n";
|
||||||
import { useStore, type Draft } from "../store";
|
import { useStore, type Draft } from "../store";
|
||||||
@@ -143,8 +144,8 @@ export function ActivityLine({
|
|||||||
<span className="activity-text">{label}</span>
|
<span className="activity-text">{label}</span>
|
||||||
{step && (
|
{step && (
|
||||||
<span className="activity-meta">
|
<span className="activity-meta">
|
||||||
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
|
({fmtElapsed(elapsed)} · <Icon name="arrow-up" className="tiny" /> {step.estimated ? "~" : ""}
|
||||||
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
|
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · <Icon name="arrow-down" className="tiny" /> {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,8 +164,8 @@ export function useTitleActivity() {
|
|||||||
const waiting = useStore((s) => s.permissions.length + s.questions.length);
|
const waiting = useStore((s) => s.permissions.length + s.questions.length);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = "CodeCraft";
|
const base = "CodeCraft";
|
||||||
if (waiting > 0) document.title = `● ${t("activity.titleInput")} – ${base}`;
|
if (waiting > 0) document.title = `(!) ${t("activity.titleInput")} – ${base}`;
|
||||||
else if (busyCount > 0) document.title = `⏳ ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
|
else if (busyCount > 0) document.title = `(${busyCount}) ${t("activity.titleBusy", { n: busyCount })} – ${base}`;
|
||||||
else document.title = base;
|
else document.title = base;
|
||||||
}, [busyCount, waiting, t]);
|
}, [busyCount, waiting, t]);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
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<typeof useT> }) {
|
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
|
||||||
if (!r) return <span className="muted">–</span>;
|
if (!r) return <span className="muted">–</span>;
|
||||||
if (r.error) return <span className="muted" title={r.error}>⚠ {t("budget.remoteError")}</span>;
|
if (r.error) return <span className="muted" title={r.error}><Icon name="warning" /> {t("budget.remoteError")}</span>;
|
||||||
const cur = r.currency ?? "USD";
|
const cur = r.currency ?? "USD";
|
||||||
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
|
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
|
||||||
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
|
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { del, get, patch, post } from "../api";
|
import { del, get, patch, post } from "../api";
|
||||||
import { useModels } from "../hooks";
|
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 { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
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 { useReasoningMode } from "../prefs";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
|
import { RenameDialog } from "./RenameDialog";
|
||||||
import { QueueList } from "./QueueList";
|
import { QueueList } from "./QueueList";
|
||||||
import { BudgetChip } from "./Budget";
|
import { BudgetChip } from "./Budget";
|
||||||
import { PermissionCard, QuestionCard } from "./Interaction";
|
import { PermissionCard, QuestionCard } from "./Interaction";
|
||||||
@@ -47,7 +49,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
|
|||||||
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
<button className="reasoning-head" onClick={() => setToggled(!open)} aria-expanded={open}>
|
||||||
{live ? <Dots /> : <ThinkIcon />}
|
{live ? <Dots /> : <ThinkIcon />}
|
||||||
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
<span>{live ? t("chat.reasoningLive") : t("chat.reasoningDone", { n: words })}</span>
|
||||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
<Icon name={open ? "chevron-down" : "chevron-right"} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
|
<div ref={bodyRef} className={`body ${live ? "streaming-text" : ""}`}>
|
||||||
@@ -240,27 +242,22 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
{editingTitle ? (
|
<h1 onDoubleClick={() => !readOnly && setEditingTitle(true)} title={readOnly ? undefined : t("chat.renameHint")}>
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
defaultValue={session.title}
|
|
||||||
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
|
|
||||||
{session.parent_id && (
|
{session.parent_id && (
|
||||||
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
|
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
|
||||||
↩
|
<Icon name="undo" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{session.title || t("session.untitled")}
|
{session.title || t("session.untitled")}
|
||||||
</h1>
|
{!readOnly && (
|
||||||
|
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</h1>
|
||||||
<div className="path">{session.cwd}</div>
|
<div className="path">{session.cwd}</div>
|
||||||
</div>
|
</div>
|
||||||
{ctxShare !== undefined && (
|
{ctxShare !== undefined && (
|
||||||
@@ -271,8 +268,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
{stats && (
|
{stats && (
|
||||||
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
|
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
|
||||||
<span className="hide-mobile">
|
<span className="hide-mobile">
|
||||||
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
|
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" />{" "}
|
||||||
{fmtCompact(stats.total.output_tokens)}↓ ·{" "}
|
{fmtCompact(stats.total.output_tokens)}<Icon name="arrow-down" className="tiny" /> ·{" "}
|
||||||
</span>
|
</span>
|
||||||
{fmtCost(stats.total.cost, stats.currency)}
|
{fmtCost(stats.total.cost, stats.currency)}
|
||||||
</button>
|
</button>
|
||||||
@@ -285,17 +282,17 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<>
|
<>
|
||||||
{!session.parent_id && (
|
{!session.parent_id && (
|
||||||
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||||
⇄
|
<Icon name="share" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!session.parent_id && (
|
{!session.parent_id && (
|
||||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||||
🔀
|
<Icon name="fork" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<details className="menu-pop">
|
<details className="menu-pop">
|
||||||
<summary className="small ghost" title={t("chat.export")}>
|
<summary className="small ghost" title={t("chat.export")}>
|
||||||
💾
|
<Icon name="download" />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="menu-pop-body">
|
<div className="menu-pop-body">
|
||||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
@@ -307,14 +304,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||||
⇊
|
<Icon name="compact" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||||
🗑
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||||
<div
|
<div
|
||||||
className="chat-scroll"
|
className="chat-scroll"
|
||||||
@@ -375,7 +373,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
{!busy && !readOnly && (
|
{!busy && !readOnly && (
|
||||||
<div className="msg-actions">
|
<div className="msg-actions">
|
||||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||||
✎
|
<Icon name="edit" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -417,7 +415,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
<div key={e.id} className="error-box" role="alert">
|
<div key={e.id} className="error-box" role="alert">
|
||||||
<span>{e.message}</span>
|
<span>{e.message}</span>
|
||||||
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
|
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -438,11 +436,12 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
|
|||||||
{!following && (
|
{!following && (
|
||||||
<div className="jump-latest">
|
<div className="jump-latest">
|
||||||
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||||
↓
|
<Icon name="arrow-down" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||||
@@ -500,3 +499,32 @@ function fmtK(n: number) {
|
|||||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||||
return String(n);
|
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 (
|
||||||
|
<div className="phase-bar" aria-label="Craft progress">
|
||||||
|
{phases.map((p, i) => {
|
||||||
|
const state = i < cur || (p === "ready" && cur === i) ? "done" : i === cur ? "current" : "";
|
||||||
|
return (
|
||||||
|
<span key={p} style={{ display: "contents" }}>
|
||||||
|
{i > 0 && <span className={`sep ${i <= cur ? "done" : ""}`} />}
|
||||||
|
<span className={`phase ${state}`}>
|
||||||
|
<span className="dot">
|
||||||
|
{state === "done" && (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m5 12 4.5 4.5L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{t(`phase.${p}` as Key)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { formatSize } from "./FileFigure";
|
import { formatSize } from "./FileFigure";
|
||||||
|
import { uploadFile } from "../api";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAgents, useFetch } from "../hooks";
|
import { useAgents, useFetch } from "../hooks";
|
||||||
import { useT, type Key } from "../i18n";
|
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. */
|
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
|
||||||
async function readImage(file: File): Promise<ImageData> {
|
export async function readImage(file: File): Promise<ImageData> {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
|
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
|
||||||
const canvas = document.createElement("canvas");
|
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 }) });
|
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.
|
// 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" });
|
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" });
|
await uploadFile(path, f).catch((e) => {
|
||||||
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
|
throw new Error(`${f.name}: ${e.message}`);
|
||||||
|
});
|
||||||
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
|
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(e.message ?? String(e));
|
toast(e.message ?? String(e));
|
||||||
@@ -229,10 +232,10 @@ export function Composer({
|
|||||||
<div className="attachments">
|
<div className="attachments">
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<div className="att file" key={f.path} title={f.path}>
|
<div className="att file" key={f.path} title={f.path}>
|
||||||
<span>📄 {f.name}</span>
|
<span><Icon name="file" /> {f.name}</span>
|
||||||
<small>{formatSize(f.size)}</small>
|
<small>{formatSize(f.size)}</small>
|
||||||
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
|
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -245,7 +248,7 @@ export function Composer({
|
|||||||
<div className="att" key={i}>
|
<div className="att" key={i}>
|
||||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -310,14 +313,14 @@ export function Composer({
|
|||||||
</select>
|
</select>
|
||||||
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
|
||||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||||
📎
|
<Icon name="clip" />
|
||||||
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
|
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="composer-actions">
|
<div className="composer-actions">
|
||||||
{busy && (
|
{busy && (
|
||||||
<button className="danger" onClick={onAbort} title="Esc">
|
<button className="danger" onClick={onAbort} title="Esc">
|
||||||
■ {t("composer.stop")}
|
<Icon name="stop" /> {t("composer.stop")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
|
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
/** Human-readable file size. */
|
/** Human-readable file size. */
|
||||||
@@ -137,7 +138,7 @@ export function FileChip({ url, name, size, sizeText }: { url?: string; name: st
|
|||||||
const type = fileType(name, meta.mime);
|
const type = fileType(name, meta.mime);
|
||||||
return (
|
return (
|
||||||
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
|
<a className="file-chip" href={url} target="_blank" rel="noreferrer" title={name}>
|
||||||
<span className="file-chip-icon">📎</span>
|
<Icon name="clip" className="file-chip-icon" />
|
||||||
<span className="file-chip-name">{basename(name)}</span>
|
<span className="file-chip-name">{basename(name)}</span>
|
||||||
{type && <span className="file-badge">{type}</span>}
|
{type && <span className="file-badge">{type}</span>}
|
||||||
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
|
{(sizeText || sz !== undefined) && <span className="file-chip-size">{sizeText ?? formatSize(sz!)}</span>}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { del, get, post, put } from "../api";
|
import { del, get, post, put } from "../api";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
@@ -45,13 +46,13 @@ const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|||||||
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
|
||||||
|
|
||||||
function icon(e: Entry) {
|
function icon(e: Entry) {
|
||||||
if (e.is_dir) return "📁";
|
if (e.is_dir) return <Icon name="folder" className="ficon dir" />;
|
||||||
if (IMAGE.test(e.name)) return "🖼";
|
if (IMAGE.test(e.name)) return <Icon name="image" className="ficon" />;
|
||||||
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
|
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return <Icon name="music" className="ficon" />;
|
||||||
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
|
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return <Icon name="cube" className="ficon" />;
|
||||||
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
|
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return <Icon name="file-doc" className="ficon" />;
|
||||||
if (/\.(md|txt)$/i.test(e.name)) return "📝";
|
if (/\.(md|txt)$/i.test(e.name)) return <Icon name="file-text" className="ficon" />;
|
||||||
return "📄";
|
return <Icon name="file" className="ficon" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Files({ onMenu }: { onMenu: () => void }) {
|
export function Files({ onMenu }: { onMenu: () => void }) {
|
||||||
@@ -243,13 +244,13 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
)}
|
)}
|
||||||
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
|
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
|
||||||
+ 📄
|
<Icon name="plus" className="tiny" /> <Icon name="file" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
|
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
|
||||||
+ 📁
|
<Icon name="plus" className="tiny" /> <Icon name="folder" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
|
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
|
||||||
⤒
|
<Icon name="upload" />
|
||||||
</button>
|
</button>
|
||||||
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
|
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||||
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
|
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
|
||||||
@@ -277,7 +278,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
) : (
|
) : (
|
||||||
hits.map((h) => (
|
hits.map((h) => (
|
||||||
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
|
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
|
||||||
<span>{h.is_dir ? "📁" : "📄"}</span>
|
<Icon name={h.is_dir ? "folder" : "file"} className="ficon" />
|
||||||
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
|
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -286,7 +287,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<>
|
<>
|
||||||
{listing?.parent && (
|
{listing?.parent && (
|
||||||
<button className="entry" onClick={() => load(listing.parent!)}>
|
<button className="entry" onClick={() => load(listing.parent!)}>
|
||||||
<span>↰</span>
|
<Icon name="folder-up" className="ficon" />
|
||||||
<span className="name">..</span>
|
<span className="name">..</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -298,11 +299,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
|
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
|
||||||
<span>{icon(e)}</span>
|
<span>{icon(e)}</span>
|
||||||
<span className="name">{e.name}</span>
|
<span className="name">{e.name}</span>
|
||||||
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>}
|
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark === "•" ? <Icon name="dot" className="tiny" /> : mark}</span>}
|
||||||
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
|
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
|
||||||
</button>
|
</button>
|
||||||
<details className="entry-menu">
|
<details className="entry-menu">
|
||||||
<summary aria-label={t("files.actions")}>⋯</summary>
|
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
|
||||||
<div className="menu">
|
<div className="menu">
|
||||||
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
<button onClick={() => rename(e)}>{t("files.rename")}</button>
|
||||||
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
|
||||||
@@ -333,7 +334,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<div className="editor-pane">
|
<div className="editor-pane">
|
||||||
<div className="editor-bar">
|
<div className="editor-bar">
|
||||||
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
|
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
|
||||||
←
|
<Icon name="arrow-left" />
|
||||||
</button>
|
</button>
|
||||||
<b className="grow">{pane.title}</b>
|
<b className="grow">{pane.title}</b>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,11 +350,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<div className="editor-pane">
|
<div className="editor-pane">
|
||||||
<div className="editor-bar">
|
<div className="editor-bar">
|
||||||
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
|
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
|
||||||
←
|
<Icon name="arrow-left" />
|
||||||
</button>
|
</button>
|
||||||
<b className="grow" title={f.path}>
|
<b className="grow" title={f.path}>
|
||||||
{baseName(f.path)}
|
{baseName(f.path)}
|
||||||
{dirty && <span className="dirty"> ●</span>}
|
{dirty && <span className="dirty"> <Icon name="dot" className="tiny" /></span>}
|
||||||
</b>
|
</b>
|
||||||
{(isMd || isImage) && !f.preview && (
|
{(isMd || isImage) && !f.preview && (
|
||||||
<button className="small" onClick={() => setPreview(!preview)}>
|
<button className="small" onClick={() => setPreview(!preview)}>
|
||||||
@@ -361,7 +362,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a className="button small" href={rawUrl(f.path, true)}>
|
<a className="button small" href={rawUrl(f.path, true)}>
|
||||||
⤓
|
<Icon name="download" />
|
||||||
</a>
|
</a>
|
||||||
{!f.binary && (
|
{!f.binary && (
|
||||||
<button className="small primary" disabled={!dirty} onClick={() => save()}>
|
<button className="small primary" disabled={!dirty} onClick={() => save()}>
|
||||||
@@ -400,7 +401,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
|
|||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("nav.files")}</h1>
|
<h1>{t("nav.files")}</h1>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import { fmtRelative, useT } from "../i18n";
|
import { fmtRelative, useT } from "../i18n";
|
||||||
@@ -104,17 +105,17 @@ export function GitPanel({
|
|||||||
{f.path}
|
{f.path}
|
||||||
</button>
|
</button>
|
||||||
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
|
<button className="small ghost" title={t("git.openFile")} onClick={() => openFile(abs(f))}>
|
||||||
↗
|
<Icon name="external" />
|
||||||
</button>
|
</button>
|
||||||
{isStaged ? (
|
{isStaged ? (
|
||||||
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
|
<button className="small" title={t("git.unstage")} onClick={() => run("unstage", () => call("unstage", { files: [abs(f)] }))}>
|
||||||
−
|
<Icon name="minus" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{f.index !== "?" && (
|
{f.index !== "?" && (
|
||||||
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
|
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
|
||||||
↺
|
<Icon name="refresh" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
|
<button className="small" title={t("git.stage")} onClick={() => run("stage", () => call("stage", { files: [abs(f)] }))}>
|
||||||
@@ -153,7 +154,7 @@ export function GitPanel({
|
|||||||
</button>
|
</button>
|
||||||
{status.upstream && (
|
{status.upstream && (
|
||||||
<span className="chip" title={status.upstream}>
|
<span className="chip" title={status.upstream}>
|
||||||
↑{status.ahead} ↓{status.behind}
|
<Icon name="arrow-up" className="tiny" />{status.ahead} <Icon name="arrow-down" className="tiny" />{status.behind}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
|
|||||||
@@ -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<string, ReactNode> = {
|
||||||
|
menu: <path d="M4 6h16M4 12h16M4 18h16" />,
|
||||||
|
close: <path d="M6 6l12 12M18 6 6 18" />,
|
||||||
|
check: <path d="m5 12.5 4.5 4.5L19 7" />,
|
||||||
|
"x-mark": <path d="M7 7l10 10M17 7 7 17" />,
|
||||||
|
"arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />,
|
||||||
|
"arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />,
|
||||||
|
"arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />,
|
||||||
|
"chevron-right": <path d="m9 6 6 6-6 6" />,
|
||||||
|
"chevron-down": <path d="m6 9 6 6 6-6" />,
|
||||||
|
share: (
|
||||||
|
<>
|
||||||
|
<circle cx="18" cy="5.5" r="2.5" />
|
||||||
|
<circle cx="6" cy="12" r="2.5" />
|
||||||
|
<circle cx="18" cy="18.5" r="2.5" />
|
||||||
|
<path d="m8.2 10.8 7.6-4M8.2 13.2l7.6 4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
fork: (
|
||||||
|
<>
|
||||||
|
<circle cx="6" cy="5" r="2" />
|
||||||
|
<circle cx="18" cy="5" r="2" />
|
||||||
|
<circle cx="12" cy="19" r="2" />
|
||||||
|
<path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
||||||
|
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
||||||
|
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
||||||
|
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
||||||
|
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
||||||
|
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
||||||
|
file: (
|
||||||
|
<>
|
||||||
|
<path d="M6 3h8l4 4v14H6Z" />
|
||||||
|
<path d="M14 3v4h4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"file-text": (
|
||||||
|
<>
|
||||||
|
<path d="M6 3h8l4 4v14H6Z" />
|
||||||
|
<path d="M14 3v4h4M9 12h6M9 16h6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"file-doc": (
|
||||||
|
<>
|
||||||
|
<path d="M6 3h8l4 4v14H6Z" />
|
||||||
|
<path d="M14 3v4h4M9 11h6M9 14h6M9 17h4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
folder: <path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />,
|
||||||
|
"folder-up": (
|
||||||
|
<>
|
||||||
|
<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />
|
||||||
|
<path d="M12 16v-5M9.5 13.5 12 11l2.5 2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
folders: (
|
||||||
|
<>
|
||||||
|
<path d="M7 8a1 1 0 0 1 1-1h4l1.5 1.5H20a1 1 0 0 1 1 1V18a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1Z" />
|
||||||
|
<path d="M4 15V6a1 1 0 0 1 1-1h4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
image: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
<circle cx="9" cy="10" r="1.8" />
|
||||||
|
<path d="m4 17 5-4 4 3 3-2 4 3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
music: (
|
||||||
|
<>
|
||||||
|
<path d="M9 18V6l11-2v12" />
|
||||||
|
<circle cx="6.5" cy="18" r="2.5" />
|
||||||
|
<circle cx="17.5" cy="16" r="2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
cube: (
|
||||||
|
<>
|
||||||
|
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
|
||||||
|
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
external: <path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" />,
|
||||||
|
refresh: <path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" />,
|
||||||
|
lock: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3M12 15v2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
question: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5V14M12 17h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"radio-on": (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<circle cx="12" cy="12" r="3.5" className="fill" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"radio-off": <circle cx="12" cy="12" r="8" />,
|
||||||
|
"box-on": (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||||
|
<path d="m8 12 3 3 5-6" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
"box-off": <rect x="4" y="4" width="16" height="16" rx="3" />,
|
||||||
|
play: <path d="M8 5v14l11-7Z" className="fill" />,
|
||||||
|
stop: <rect x="6" y="6" width="12" height="12" rx="2" className="fill" />,
|
||||||
|
warning: <path d="M12 4 2.5 20h19ZM12 10v4.5M12 17.5h.01" />,
|
||||||
|
brain: (
|
||||||
|
<>
|
||||||
|
<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1V5a2 2 0 0 0-3-1Z" />
|
||||||
|
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
eye: (
|
||||||
|
<>
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
gear: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1M18.7 18.7l-2.1-2.1M7.4 7.4 5.3 5.3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
robot: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="8" width="14" height="11" rx="3" />
|
||||||
|
<path d="M12 4v4M9 13h.01M15 13h.01M9.5 16h5M3 12v3M21 12v3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plug: <path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0ZM12 17v4" />,
|
||||||
|
shield: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
users: (
|
||||||
|
<>
|
||||||
|
<circle cx="9" cy="8" r="3.2" />
|
||||||
|
<path d="M3 20a6 6 0 0 1 12 0M16 4.5a3.2 3.2 0 0 1 0 6.3M18 14.5a6 6 0 0 1 3 5.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
user: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="8" r="3.8" />
|
||||||
|
<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
toolbox: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="8" width="18" height="11" rx="2" />
|
||||||
|
<path d="M9 8V5h6v3M3 13h18M10 12v2M14 12v2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
scroll: <path d="M7 4h11a2 2 0 0 1 0 4h-1v10a2 2 0 0 1-2 2H6a2 2 0 0 1 0-4h1ZM10 9h4M10 12h4" />,
|
||||||
|
terminal: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
<path d="m7 9 3 3-3 3M12 15h5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
logout: <path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5M15 8l4 4-4 4M19 12H9" />,
|
||||||
|
chart: <path d="M4 20V4M4 20h16M8 16v-4M12 16V8M16 16v-6M20 16V6" />,
|
||||||
|
wrench: <path d="M14.5 5.5a4 4 0 0 0 5 5L11 19a2.1 2.1 0 0 1-3-3l8.5-8.5a4 4 0 0 1-2-2Z" />,
|
||||||
|
sun: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2.5v2M12 19.5v2M21.5 12h-2M4.5 12h-2M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4M18.7 18.7l-1.4-1.4M6.7 6.7 5.3 5.3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
moon: <path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />,
|
||||||
|
"theme-auto": (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M12 3.5v17a8.5 8.5 0 0 0 0-17Z" className="fill" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
|
||||||
|
plus: <path d="M12 5v14M5 12h14" />,
|
||||||
|
minus: <path d="M5 12h14" />,
|
||||||
|
upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />,
|
||||||
|
more: (
|
||||||
|
<>
|
||||||
|
<circle cx="5" cy="12" r="1.6" className="fill" />
|
||||||
|
<circle cx="12" cy="12" r="1.6" className="fill" />
|
||||||
|
<circle cx="19" cy="12" r="1.6" className="fill" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
copy: (
|
||||||
|
<>
|
||||||
|
<rect x="8" y="8" width="12" height="12" rx="2" />
|
||||||
|
<path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
sparkle: <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconName = keyof typeof P;
|
||||||
|
|
||||||
|
export function Icon({ name, size, className, title }: { name: string; size?: number; className?: string; title?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`icon ${className ?? ""}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
aria-hidden={title ? undefined : true}
|
||||||
|
role={title ? "img" : undefined}
|
||||||
|
>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
{P[name] ?? P.dot}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { del, post } from "../api";
|
import { del, post } from "../api";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
@@ -63,7 +64,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
|
|||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("nav.images")}</h1>
|
<h1>{t("nav.images")}</h1>
|
||||||
@@ -141,16 +142,16 @@ export function Images({ onMenu }: { onMenu: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 4 }}>
|
<div className="row" style={{ gap: 4 }}>
|
||||||
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
|
<a className="button small" href={fileUrl(img.id)} download={img.path.split("/").pop()}>
|
||||||
⤓
|
<Icon name="download" />
|
||||||
</a>
|
</a>
|
||||||
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
|
<button className="small" title={t("images.reuse")} onClick={() => setForm({ ...form, prompt: img.prompt, model: img.model })}>
|
||||||
↺
|
<Icon name="refresh" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
|
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
|
||||||
⧉
|
<Icon name="copy" />
|
||||||
</button>
|
</button>
|
||||||
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
|
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
|
||||||
🗑
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { post } from "../api";
|
import { post } from "../api";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
@@ -43,7 +44,7 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
|||||||
return (
|
return (
|
||||||
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||||
<h3>
|
<h3>
|
||||||
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
<Icon name="lock" /> {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||||
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
||||||
@@ -122,7 +123,7 @@ export function QuestionCard({ req, currentId }: { req: QuestionRequest; current
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" role="dialog" aria-label={t("question.title")}>
|
<div className="card" role="dialog" aria-label={t("question.title")}>
|
||||||
<h3>❓ {t("question.title")}</h3>
|
<h3><Icon name="question" /> {t("question.title")}</h3>
|
||||||
<div className="from">{source}</div>
|
<div className="from">{source}</div>
|
||||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||||
{req.questions.map((q, qi) => (
|
{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)}
|
onClick={() => toggle(qi, o.label, !!q.multi_select)}
|
||||||
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
||||||
>
|
>
|
||||||
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span>
|
<Icon name={q.multi_select ? (isSel ? "box-on" : "box-off") : isSel ? "radio-on" : "radio-off"} />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{o.label === OTHER ? (
|
{o.label === OTHER ? (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { memo, useState, type ReactNode } from "react";
|
import { memo, useState, type ReactNode } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -36,7 +37,7 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
|
|||||||
<img src={url} alt="SVG" />
|
<img src={url} alt="SVG" />
|
||||||
</FileFigure>
|
</FileFigure>
|
||||||
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
|
||||||
{showCode ? "▾ SVG" : "▸ SVG"}
|
<Icon name={showCode ? "chevron-down" : "chevron-right"} /> SVG
|
||||||
</button>
|
</button>
|
||||||
{showCode && children}
|
{showCode && children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
|
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
|
||||||
// Loaded on demand only when such a file is opened.
|
// Loaded on demand only when such a file is opened.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -147,7 +148,7 @@ export function ModelViewer({ url, ext }: { url: string; ext: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="model-viewer">
|
<div className="model-viewer">
|
||||||
<div ref={host} className="model-canvas" />
|
<div ref={host} className="model-canvas" />
|
||||||
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div>
|
<div className="model-info">{state.loading ? "…" : state.error ? <><Icon name="warning" /> {state.error}</> : state.info}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Icon } from "./Icon";
|
||||||
import { post } from "../api";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { post, uploadFile } from "../api";
|
||||||
import { useAgents, useFetch } from "../hooks";
|
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";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
|
||||||
interface DirListing {
|
interface DirListing {
|
||||||
@@ -14,64 +18,560 @@ interface DirListing {
|
|||||||
|
|
||||||
const LAST_DIR = "codecraft.lastDir";
|
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) => (
|
||||||
|
<svg viewBox="0 0 24 24" className="wiz-icon" aria-hidden="true">
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const ICONS: Record<string, ReactNode> = {
|
||||||
|
build: svg(<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16" />),
|
||||||
|
plan: svg(
|
||||||
|
<>
|
||||||
|
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
|
||||||
|
<path d="M9 4v14M15 6v14" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
craft: svg(
|
||||||
|
<>
|
||||||
|
<path d="m4 20 10-10M14 10l2-2" />
|
||||||
|
<path d="M17 3v3M15.5 4.5h3M20 8v2M19 9h2M11 3v2M10 4h2" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
agent: svg(
|
||||||
|
<>
|
||||||
|
<rect x="5" y="7" width="14" height="11" rx="3" />
|
||||||
|
<path d="M12 3v4M9 12h.01M15 12h.01M9.5 15h5" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
website: svg(
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3c2.5 2.8 3.5 5.8 3.5 9s-1 6.2-3.5 9c-2.5-2.8-3.5-5.8-3.5-9s1-6.2 3.5-9Z" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
app: svg(
|
||||||
|
<>
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
<path d="M3 9h18M6 7h.01M8.5 7h.01" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
game: svg(
|
||||||
|
<>
|
||||||
|
<path d="M7 9h10a4 4 0 0 1 4 4v1a3 3 0 0 1-5.4 1.8L14.5 14h-5l-1.1 1.8A3 3 0 0 1 3 14v-1a4 4 0 0 1 4-4Z" />
|
||||||
|
<path d="M8 11.5v3M6.5 13h3M15.5 12h.01M17.5 13.5h.01" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
software: svg(
|
||||||
|
<>
|
||||||
|
<path d="M5 3h11l3 3v15H5Z" />
|
||||||
|
<path d="M8 3v5h7V3M8 21v-7h8v7" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
fresh: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z" />),
|
||||||
|
clean: svg(
|
||||||
|
<>
|
||||||
|
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
deep: svg(
|
||||||
|
<>
|
||||||
|
<circle cx="11" cy="11" r="6" />
|
||||||
|
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
|
||||||
|
git: svg(
|
||||||
|
<>
|
||||||
|
<circle cx="6" cy="6" r="2.2" />
|
||||||
|
<circle cx="6" cy="18" r="2.2" />
|
||||||
|
<circle cx="18" cy="9" r="2.2" />
|
||||||
|
<path d="M6 8.2v7.6M18 11.2c0 3.5-4 3-9.6 5.6" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
|
||||||
|
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
|
||||||
|
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
|
||||||
|
};
|
||||||
|
|
||||||
|
function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={`wiz-card ${active ? "active" : ""}`} onClick={onClick} aria-pressed={active}>
|
||||||
|
{ICONS[icon] ?? ICONS.agent}
|
||||||
|
<span className="wiz-card-text">
|
||||||
|
<span className="wiz-card-title">{title}</span>
|
||||||
|
{desc && <span className="wiz-card-desc">{desc}</span>}
|
||||||
|
</span>
|
||||||
|
{active && (
|
||||||
|
<svg viewBox="0 0 24 24" className="wiz-check" aria-hidden="true">
|
||||||
|
<path d="m5 12 4.5 4.5L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="wiz-step">
|
||||||
|
<h3>
|
||||||
|
<span className="wiz-num">{n}</span>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [path, setPath] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
|
||||||
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||||
const { data: listing, error: dirError } = useFetch<DirListing>(`/fs/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
|
||||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
const config = useFetch<{ default_model?: string }>("/config").data;
|
||||||
|
const [task, setTask] = useState<Task>("craft");
|
||||||
|
const [target, setTarget] = useState<Target>("fresh");
|
||||||
|
const [deep, setDeep] = useState(false);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [images, setImages] = useState<ImageData[]>([]);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [agent, setAgent] = useState("build");
|
// Folder: a new one (name) inside a root, or an existing one (picked).
|
||||||
const [prompt, setPrompt] = useState("");
|
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<File[]>([]);
|
||||||
|
const [progress, setProgress] = useState<Record<string, number>>({});
|
||||||
|
const originalInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [folderName, setFolderName] = useState("");
|
||||||
|
const [nameTouched, setNameTouched] = useState(false);
|
||||||
|
const [rootPath, setRootPath] = useState("");
|
||||||
|
const [picked, setPicked] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState<string>();
|
||||||
const agents = useAgents(listing?.path).filter((a) => a.mode === "primary");
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
|
||||||
|
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model && config?.default_model) setModel(config.default_model);
|
if (!model && config?.default_model) setModel(config.default_model);
|
||||||
}, [config, model]);
|
}, [config, model]);
|
||||||
|
|
||||||
// A remembered directory that vanished: fall back to the workspace root.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dirError && path) setPath("");
|
if (!rootPath && roots.length) setRootPath(roots[roots.length - 1].path);
|
||||||
}, [dirError, 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 () => {
|
const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
|
||||||
if (!listing) return;
|
const autoName = useMemo(
|
||||||
setCreating(true);
|
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"),
|
||||||
setError(undefined);
|
[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, Key[]>)[target];
|
||||||
|
|
||||||
|
const addFiles = async (list: FileList | File[]) => {
|
||||||
|
const all = [...list];
|
||||||
|
const imgs = all.filter((f) => f.type.startsWith("image/") && target !== "software");
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LAST_DIR, listing.path);
|
const read = await Promise.all(imgs.map(readImage));
|
||||||
const s = await post<{ id: string }>("/sessions", { cwd: listing.path, model: model || undefined, agent });
|
setImages((cur) => [...cur, ...read]);
|
||||||
if (prompt.trim()) await post(`/sessions/${s.id}/prompt`, { text: prompt.trim() });
|
|
||||||
onCreated(s.id);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
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 create = async () => {
|
||||||
const currentRoot = listing?.root;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("new.title")}</h1>
|
<h1>{t("new.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-inner" style={{ maxWidth: 760 }}>
|
<div className="page-inner wizard">
|
||||||
<div className="panel">
|
<h2 className="wiz-title">{t("wizard.title")}</h2>
|
||||||
<h3>{t("new.directory")}</h3>
|
<p className="wiz-sub">{t("wizard.subtitle")}</p>
|
||||||
|
|
||||||
|
<div className="panel wiz-panel">
|
||||||
|
<Step n={n++} title={t("wizard.whatToDo")}>
|
||||||
|
<div className="wiz-cards">
|
||||||
|
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
|
||||||
|
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
|
||||||
|
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
|
||||||
|
{custom.map((a) => (
|
||||||
|
<Card key={a.name} icon="agent" title={a.name} desc={a.description} active={task === a.name} onClick={() => setTask(a.name)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
{task === "craft" && (
|
||||||
|
<Step n={n++} title={t("wizard.whatToRecreate")}>
|
||||||
|
<div className="wiz-cards five">
|
||||||
|
{(["website", "app", "game", "software", "fresh"] as Target[]).map((x) => (
|
||||||
|
<Card key={x} icon={x} title={t(`wizard.target.${x}` as Key)} active={target === x} onClick={() => setTarget(x)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{target !== "fresh" && (
|
||||||
|
<div className="wiz-cards two" style={{ marginTop: 12 }}>
|
||||||
|
<Card icon="clean" title={t("craft.cleanRoom")} desc={t("wizard.cleanDesc")} active={!deep} onClick={() => setDeep(false)} />
|
||||||
|
<Card icon="deep" title={t("craft.deep")} desc={t("wizard.deepDesc")} active={deep} onClick={() => setDeep(true)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{target === "software" && !deep && <div className="notice">{t("wizard.softwareNeedsDeep")}</div>}
|
||||||
|
{target === "software" && (
|
||||||
|
<div
|
||||||
|
className="wiz-drop"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOriginals((c) => [...c, ...e.dataTransfer.files]);
|
||||||
|
}}
|
||||||
|
onClick={() => originalInput.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{ICONS.upload}
|
||||||
|
<div>
|
||||||
|
<b>{t("wizard.originalTitle")}</b>
|
||||||
|
<div className="hint">{t("wizard.originalHint")}</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={originalInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files) setOriginals((c) => [...c, ...e.target.files!]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{target === "software" && originals.length > 0 && (
|
||||||
|
<div className="wiz-originals">
|
||||||
|
{originals.map((f, i) => (
|
||||||
|
<div key={`${f.name}${i}`} className="wiz-original">
|
||||||
|
<span className="name">{f.name}</span>
|
||||||
|
<span className="size">{formatSize(f.size)}</span>
|
||||||
|
{progress[f.name] !== undefined ? (
|
||||||
|
<span className="bar">
|
||||||
|
<i style={{ width: `${Math.round(progress[f.name] * 100)}%` }} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button className="small ghost" onClick={() => setOriginals((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Step n={n++} title={t("wizard.sayWhat")}>
|
||||||
|
{needsUrl && (
|
||||||
|
<input
|
||||||
|
className="wiz-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="wiz-input"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : "wizard.placeholder.task")}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
if (e.clipboardData.files.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles(e.clipboardData.files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !matchMedia("(pointer: coarse)").matches && canStart) {
|
||||||
|
e.preventDefault();
|
||||||
|
void create();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(images.length > 0 || files.length > 0) && (
|
||||||
|
<div className="attachments">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<div className="att" key={`i${i}`}>
|
||||||
|
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
||||||
|
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div className="att file" key={`f${i}`}>
|
||||||
|
<span><Icon name="file" /> {f.name}</span>
|
||||||
|
<small>{formatSize(f.size)}</small>
|
||||||
|
<button onClick={() => setFiles((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="wiz-input-foot">
|
||||||
|
<button type="button" className="ghost small" onClick={() => fileInput.current?.click()} title={t("wizard.attach")}>
|
||||||
|
{ICONS.clip}
|
||||||
|
</button>
|
||||||
|
<span>{target === "software" && task === "craft" ? t("wizard.softwareHint") : t("wizard.enterHint")}</span>
|
||||||
|
<input ref={fileInput} type="file" multiple hidden onChange={(e) => e.target.files && void addFiles(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ideas && (
|
||||||
|
<div className="wiz-ideas">
|
||||||
|
<span>{t("wizard.ideas")}</span>
|
||||||
|
{ideas.map((k) => (
|
||||||
|
<button key={k} type="button" className="chip-btn" onClick={() => setText(t(k))}>
|
||||||
|
{t(k)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<div className="wiz-foot">
|
||||||
|
<div className="wiz-foot-row">
|
||||||
|
<label>{t("wizard.builtBy")}</label>
|
||||||
|
<ModelSelect value={model} onChange={setModel} />
|
||||||
|
</div>
|
||||||
|
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
|
||||||
|
{(["new", "existing", "git"] as const).map((m) => (
|
||||||
|
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
|
||||||
|
{ICONS[m === "git" ? "git" : "folder"]}
|
||||||
|
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{folderMode === "git" && (
|
||||||
|
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
|
||||||
|
)}
|
||||||
|
{folderMode !== "existing" && (
|
||||||
|
<div className="wiz-folder">
|
||||||
|
{ICONS.folder}
|
||||||
|
<span className="wiz-folder-text">
|
||||||
|
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
|
||||||
|
{roots.length > 1 ? (
|
||||||
|
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
|
||||||
|
{roots.map((r) => (
|
||||||
|
<option key={r.path} value={r.path}>
|
||||||
|
{r.name === "/" ? "workspace" : r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
|
||||||
|
)}
|
||||||
|
<span>/</span>
|
||||||
|
<input
|
||||||
|
className="wiz-folder-name"
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFolderName(e.target.value);
|
||||||
|
setNameTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="wiz-start-row">
|
||||||
|
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
|
||||||
|
{ICONS.start}
|
||||||
|
{creating ?? t("wizard.start")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browse the user's folders and pick one. */
|
||||||
|
function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [browse, setBrowse] = useState(path);
|
||||||
|
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
|
||||||
|
const { data: listing, error } = useFetch<DirListing>(`/fs/dirs${browse ? `?path=${encodeURIComponent(browse)}` : ""}`);
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && browse) setBrowse("");
|
||||||
|
}, [error, browse]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (listing && listing.path !== path) onPick(listing.path);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [listing?.path]);
|
||||||
|
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||||
|
return (
|
||||||
|
<div className="wiz-picker">
|
||||||
{roots.length > 1 && (
|
{roots.length > 1 && (
|
||||||
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
||||||
{roots.map((r) => (
|
{roots.map((r) => (
|
||||||
<button key={r.path} className={currentRoot === r.path ? "active" : ""} onClick={() => setPath(r.path)}>
|
<button key={r.path} className={listing?.root === r.path ? "active" : ""} onClick={() => setBrowse(r.path)}>
|
||||||
{r.name}
|
{r.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -82,56 +582,21 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
{rel}
|
{rel}
|
||||||
</code>
|
</code>
|
||||||
{listing?.parent && (
|
{listing?.parent && (
|
||||||
<button className="small" onClick={() => setPath(listing.parent!)}>
|
<button className="small" onClick={() => setBrowse(listing.parent!)}>
|
||||||
↑ {t("new.up")}
|
<Icon name="arrow-up" /> {t("new.up")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dir-list">
|
<div className="dir-list">
|
||||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||||
{listing?.dirs.map((d) => (
|
{listing?.dirs.map((d) => (
|
||||||
<button key={d.path} onClick={() => setPath(d.path)}>
|
<button key={d.path} onClick={() => setBrowse(d.path)}>
|
||||||
<span>📁</span>
|
{ICONS.folder}
|
||||||
<span style={{ flex: 1 }}>{d.name}</span>
|
<span style={{ flex: 1 }}>{d.name}</span>
|
||||||
{d.git && <span className="chip">git</span>}
|
{d.git && <span className="chip">git</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
|
||||||
<div className="form">
|
|
||||||
<label>{t("new.model")}</label>
|
|
||||||
<ModelSelect value={model} onChange={setModel} />
|
|
||||||
<label>{t("new.mode")}</label>
|
|
||||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
|
||||||
{agents.map((a) => (
|
|
||||||
<option key={a.name} value={a.name}>
|
|
||||||
{a.name} – {a.description}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="panel">
|
|
||||||
<h3>{t("new.firstPrompt")}</h3>
|
|
||||||
<textarea
|
|
||||||
style={{ width: "100%", minHeight: 110 }}
|
|
||||||
value={prompt}
|
|
||||||
placeholder={t("composer.placeholder")}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) void create();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <div className="notice err">{error}</div>}
|
|
||||||
<button className="primary" disabled={!listing || creating} onClick={create}>
|
|
||||||
{t("new.start", { dir: rel })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { del, post } from "../api";
|
import { del, post } from "../api";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
@@ -38,7 +39,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
|||||||
title={t("queue.remove")}
|
title={t("queue.remove")}
|
||||||
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
|
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
|
||||||
>
|
>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { patch, post } from "../api";
|
||||||
|
import { useFetch } from "../hooks";
|
||||||
|
import { useT } from "../i18n";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
import { Spinner } from "./Activity";
|
||||||
|
|
||||||
|
/** Rename a session: type a name, or let a chosen model suggest one. */
|
||||||
|
export function RenameDialog({ sessionId, title, onClose }: { sessionId: string; title: string; onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const config = useFetch<{ small_model?: string; default_model?: string }>("/config").data;
|
||||||
|
const [value, setValue] = useState(title);
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [busy, setBusy] = useState<"gen" | "save">();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const chosen = model || config?.small_model || config?.default_model || "";
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy("gen");
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const r = await post<{ title: string }>(`/sessions/${sessionId}/title`, { model: chosen || undefined });
|
||||||
|
setValue(r.title);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
await patch(`/sessions/${sessionId}`, { title: value.trim() });
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" role="dialog" aria-label={t("rename.title")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("rename.title")}</h3>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
value={value}
|
||||||
|
placeholder={t("session.untitled")}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && !busy && void save()}
|
||||||
|
/>
|
||||||
|
<div className="rename-gen">
|
||||||
|
<span className="hint">{t("rename.generateWith")}</span>
|
||||||
|
<ModelSelect value={chosen} onChange={setModel} />
|
||||||
|
<button className="small" onClick={generate} disabled={!!busy}>
|
||||||
|
{busy === "gen" ? <Spinner small /> : <Icon name="sparkle" />} {t("rename.generate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" onClick={save} disabled={!!busy}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { get, patch as apiPatch, post, put } from "../api";
|
import { get, patch as apiPatch, post, put } from "../api";
|
||||||
import { useFetch, useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
|
||||||
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
|
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
|
||||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
@@ -54,7 +56,6 @@ function InterfaceSettings() {
|
|||||||
const theme = useSyncExternalStore(onThemeChange, getTheme);
|
const theme = useSyncExternalStore(onThemeChange, getTheme);
|
||||||
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||||||
const themeInfo = THEMES.find((th) => th.id === theme)!;
|
const themeInfo = THEMES.find((th) => th.id === theme)!;
|
||||||
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
|
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>{t("settings.interface")}</h3>
|
<h3>{t("settings.interface")}</h3>
|
||||||
@@ -107,18 +108,63 @@ function InterfaceSettings() {
|
|||||||
</select>
|
</select>
|
||||||
<div className="hint">{t("settings.reasoningHint")}</div>
|
<div className="hint">{t("settings.reasoningHint")}</div>
|
||||||
<label>{t("settings.notifications")}</label>
|
<label>{t("settings.notifications")}</label>
|
||||||
<div>
|
<PushControl />
|
||||||
{notif === "granted" ? (
|
<div className="hint">{t("settings.notificationsHint")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Web Push on this device: enable/disable and a test notification. */
|
||||||
|
function PushControl() {
|
||||||
|
const t = useT();
|
||||||
|
const [state, setState] = useState<"loading" | "on" | "off" | "unsupported" | "denied">("loading");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pushSupported()) return setState("unsupported");
|
||||||
|
if (Notification.permission === "denied") return setState("denied");
|
||||||
|
currentSubscription()
|
||||||
|
.then((s) => setState(s ? "on" : "off"))
|
||||||
|
.catch(() => setState("off"));
|
||||||
|
}, []);
|
||||||
|
const run = (f: () => Promise<unknown>) => async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await f();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (state === "loading") return <div />;
|
||||||
|
if (state === "unsupported") return <div className="hint">{t("settings.pushUnsupported")}</div>;
|
||||||
|
if (state === "denied") return <div className="hint">{t("settings.pushDenied")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{state === "on" ? (
|
||||||
|
<>
|
||||||
<span className="chip">{t("settings.notificationsOn")}</span>
|
<span className="chip">{t("settings.notificationsOn")}</span>
|
||||||
|
<button
|
||||||
|
className="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={run(async () => {
|
||||||
|
const n = await testPush();
|
||||||
|
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t("settings.pushTest")}
|
||||||
|
</button>
|
||||||
|
<button className="small ghost" disabled={busy} onClick={run(async () => (await disablePush(), setState("off")))}>
|
||||||
|
{t("settings.pushDisable")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
|
<button className="small" disabled={busy} onClick={run(async () => (await enablePush(), setState("on")))}>
|
||||||
{t("settings.notificationsEnable")}
|
{t("settings.notificationsEnable")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hint">{t("settings.notificationsHint")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,8 +471,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>{t("settings.context")}</th>
|
<th>{t("settings.context")}</th>
|
||||||
<th>{t("settings.maxOutput")}</th>
|
<th>{t("settings.maxOutput")}</th>
|
||||||
<th title={t("settings.reasoning")}>🧠</th>
|
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
|
||||||
<th title={t("settings.vision")}>👁</th>
|
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
||||||
<th>{t("settings.effort")}</th>
|
<th>{t("settings.effort")}</th>
|
||||||
<th>{t("settings.priceIn")}</th>
|
<th>{t("settings.priceIn")}</th>
|
||||||
<th>{t("settings.priceOut")}</th>
|
<th>{t("settings.priceOut")}</th>
|
||||||
@@ -472,7 +518,7 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -733,7 +779,7 @@ function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, str
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
×
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -819,17 +865,17 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
|
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
|
||||||
const USER_TABS = ["general", "account"] as const;
|
const USER_TABS = ["general", "account"] as const;
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
general: "⚙️",
|
general: "gear",
|
||||||
providers: "🧠",
|
providers: "brain",
|
||||||
agents: "🤖",
|
agents: "robot",
|
||||||
mcp: "🔌",
|
mcp: "plug",
|
||||||
permissions: "🛡️",
|
permissions: "shield",
|
||||||
users: "👥",
|
users: "users",
|
||||||
folders: "🗂️",
|
folders: "folders",
|
||||||
tools: "🧰",
|
tools: "toolbox",
|
||||||
audit: "📜",
|
audit: "scroll",
|
||||||
account: "👤",
|
account: "user",
|
||||||
raw: "📝",
|
raw: "file-text",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
|
||||||
@@ -843,7 +889,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
|||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("nav.settings")}</h1>
|
<h1>{t("nav.settings")}</h1>
|
||||||
@@ -854,9 +900,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
|
|||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{TABS.map((x) => (
|
{TABS.map((x) => (
|
||||||
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
|
||||||
<span className="nav-icon" aria-hidden>
|
<Icon name={TAB_ICONS[x]} className="nav-icon" />
|
||||||
{TAB_ICONS[x]}
|
|
||||||
</span>
|
|
||||||
{t(`settings.tab.${x}` as Key)}
|
{t(`settings.tab.${x}` as Key)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get, post } from "../api";
|
import { get, post } from "../api";
|
||||||
import { toast } from "../dialogs";
|
import { toast } from "../dialogs";
|
||||||
@@ -40,7 +41,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
|
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
|
||||||
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })}
|
{running ? <Spinner small /> : <Icon name="terminal" />} {t("shells.chip", { n: running, total: shells.length })}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
||||||
@@ -49,13 +50,13 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
|
|||||||
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
|
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
|
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
|
||||||
✕
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
|
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
|
||||||
{shells.map((s) => (
|
{shells.map((s) => (
|
||||||
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
|
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
|
||||||
{s.running ? "● " : "○ "}
|
<Icon name={s.running ? "dot" : "radio-off"} className={`tiny ${s.running ? "ok" : ""}`} />{" "}
|
||||||
{s.command.slice(0, 40)}
|
{s.command.slice(0, 40)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -67,7 +68,7 @@ export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boole
|
|||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{current.running && (
|
{current.running && (
|
||||||
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
|
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
|
||||||
■ {t("shells.stop")}
|
<Icon name="stop" /> {t("shells.stop")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { get, post } from "../api";
|
import { get, post } from "../api";
|
||||||
@@ -123,7 +124,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
<span>{s.title || t("session.untitled")}</span>
|
<span>{s.title || t("session.untitled")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
{s.shared && <span title={t("sidebar.shared")}>⇄ </span>}
|
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
||||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -138,25 +139,25 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
</span>
|
</span>
|
||||||
<ThemeModeButton />
|
<ThemeModeButton />
|
||||||
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
|
||||||
<span className="nav-icon" aria-hidden>🚪</span>
|
<Icon name="logout" className="nav-icon" />
|
||||||
{t("nav.logout")}
|
{t("nav.logout")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||||||
<span className="nav-icon" aria-hidden>📁</span>
|
<Icon name="folder" className="nav-icon" />
|
||||||
{t("nav.files")}
|
{t("nav.files")}
|
||||||
</button>
|
</button>
|
||||||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||||||
<span className="nav-icon" aria-hidden>🖼️</span>
|
<Icon name="image" className="nav-icon" />
|
||||||
{t("nav.images")}
|
{t("nav.images")}
|
||||||
</button>
|
</button>
|
||||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||||
<span className="nav-icon" aria-hidden>📊</span>
|
<Icon name="chart" className="nav-icon" />
|
||||||
{t("nav.stats")}
|
{t("nav.stats")}
|
||||||
</button>
|
</button>
|
||||||
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||||||
<span className="nav-icon" aria-hidden>🔧</span>
|
<Icon name="wrench" className="nav-icon" />
|
||||||
{t("nav.settings")}
|
{t("nav.settings")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +169,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
function ThemeModeButton() {
|
function ThemeModeButton() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||||||
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
|
const icon = <Icon name={mode === "auto" ? "theme-auto" : mode === "light" ? "sun" : "moon"} />;
|
||||||
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
|
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
|
||||||
return (
|
return (
|
||||||
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
|
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
|
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
|
||||||
@@ -163,7 +164,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
<>
|
<>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
☰
|
<Icon name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<h1>{t("nav.stats")}</h1>
|
<h1>{t("nav.stats")}</h1>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { base64Size, FileFigure } from "./FileFigure";
|
import { base64Size, FileFigure } from "./FileFigure";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
@@ -26,6 +27,12 @@ export function toolTitle(name: string, input: any): string {
|
|||||||
return input?.path ?? ".";
|
return input?.path ?? ".";
|
||||||
case "bash":
|
case "bash":
|
||||||
return input?.description || input?.command || "";
|
return input?.description || input?.command || "";
|
||||||
|
case "craft_phase":
|
||||||
|
return `${input?.phase ?? ""}${input?.note ? ` – ${input.note}` : ""}`;
|
||||||
|
case "web_search":
|
||||||
|
return input?.query ?? "";
|
||||||
|
case "diagnostics":
|
||||||
|
return input?.path ?? "";
|
||||||
case "glob":
|
case "glob":
|
||||||
case "grep":
|
case "grep":
|
||||||
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
|
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
|
||||||
@@ -35,7 +42,7 @@ export function toolTitle(name: string, input: any): string {
|
|||||||
case "task":
|
case "task":
|
||||||
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
|
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
|
||||||
case "image_compare":
|
case "image_compare":
|
||||||
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`;
|
return `${input?.original ?? ""} vs. ${input?.candidate ?? ""}`;
|
||||||
case "skill":
|
case "skill":
|
||||||
return input?.name ?? "";
|
return input?.name ?? "";
|
||||||
case "ask_user":
|
case "ask_user":
|
||||||
@@ -46,12 +53,12 @@ export function toolTitle(name: string, input: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TodoList({ todos }: { todos: Todo[] }) {
|
export function TodoList({ todos }: { todos: Todo[] }) {
|
||||||
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const;
|
const icon = { completed: "box-on", in_progress: "play", pending: "box-off" } as const;
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
{todos.map((td, i) => (
|
{todos.map((td, i) => (
|
||||||
<li key={i} className={td.status}>
|
<li key={i} className={td.status}>
|
||||||
<span>{icon[td.status] ?? "☐"}</span>
|
<Icon name={icon[td.status as keyof typeof icon] ?? "box-off"} />
|
||||||
<span>{td.content}</span>
|
<span>{td.content}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -63,7 +70,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const progress = useStore((s) => s.progress[call.id]);
|
const progress = useStore((s) => s.progress[call.id]);
|
||||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||||
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
||||||
const meta = result?.meta;
|
const meta = result?.meta;
|
||||||
|
|
||||||
// The live task list has its own panel; updates are a single line here.
|
// The live task list has its own panel; updates are a single line here.
|
||||||
@@ -73,8 +80,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
const current = todos.find((x) => x.status === "in_progress");
|
const current = todos.find((x) => x.status === "in_progress");
|
||||||
return (
|
return (
|
||||||
<div className="todo-line">
|
<div className="todo-line">
|
||||||
☑ {t("tool.todosUpdated", { done, total: todos.length })}
|
<Icon name="box-on" /> {t("tool.todosUpdated", { done, total: todos.length })}
|
||||||
{current && <span> · ▶ {current.content}</span>}
|
{current && <span> · <Icon name="play" className="tiny" /> {current.content}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,7 +97,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
<div className="tool-head">
|
<div className="tool-head">
|
||||||
<span className="tool-name">{call.name}</span>
|
<span className="tool-name">{call.name}</span>
|
||||||
<span className="tool-title">{call.input.path}</span>
|
<span className="tool-title">{call.input.path}</span>
|
||||||
<span className="status ok">✓</span>
|
<span className="status ok"><Icon name="check" /></span>
|
||||||
</div>
|
</div>
|
||||||
<FileFigure url={writtenImage} name={call.input.path}>
|
<FileFigure url={writtenImage} name={call.input.path}>
|
||||||
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
|
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./Icon";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFetch, useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
|
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
|
||||||
@@ -64,7 +65,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
|
|||||||
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
|
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||||
✕
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -85,7 +86,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
|
|||||||
<div className="kpi">
|
<div className="kpi">
|
||||||
<div className="k-label">{t("stats.tokens")}</div>
|
<div className="k-label">{t("stats.tokens")}</div>
|
||||||
<div className="k-value">
|
<div className="k-value">
|
||||||
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓
|
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}<Icon name="arrow-up" className="tiny" /> {fmtCompact(data.total.output_tokens)}<Icon name="arrow-down" className="tiny" />
|
||||||
</div>
|
</div>
|
||||||
<div className="k-sub">
|
<div className="k-sub">
|
||||||
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
|
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
|
||||||
@@ -96,7 +97,7 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
|
|||||||
<div className="k-value" style={{ fontSize: 15 }}>
|
<div className="k-value" style={{ fontSize: 15 }}>
|
||||||
{data.first ? fmtDate(data.first) : "–"}
|
{data.first ? fmtDate(data.first) : "–"}
|
||||||
</div>
|
</div>
|
||||||
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div>
|
<div className="k-sub">{data.last ? <><Icon name="chevron-right" className="tiny" /> {fmtDate(data.last)}</> : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,8 +119,8 @@ export function UsageOverlay({ session, onClose }: { session: Session; onClose:
|
|||||||
<Row label="ID" value={<code>{m.model}</code>} />
|
<Row label="ID" value={<code>{m.model}</code>} />
|
||||||
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
|
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
|
||||||
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
|
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
|
||||||
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} />
|
<Row label={t("settings.reasoning")} value={mi?.reasoning ? <><Icon name="check" />{mi.effort ? ` (${mi.effort})` : ""}</> : "–"} />
|
||||||
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} />
|
<Row label={t("settings.vision")} value={mi?.vision ? <Icon name="check" /> : "–"} />
|
||||||
{isCurrent && mi?.context ? (
|
{isCurrent && mi?.context ? (
|
||||||
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
|
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { Icon } from "./components/Icon";
|
||||||
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
|
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
@@ -132,7 +133,7 @@ export function DialogHost() {
|
|||||||
<div className="toasts" role="status" aria-live="polite">
|
<div className="toasts" role="status" aria-live="polite">
|
||||||
{ts.map((x) => (
|
{ts.map((x) => (
|
||||||
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
|
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
|
||||||
{x.kind === "error" ? "⚠ " : x.kind === "success" ? "✓ " : ""}
|
{x.kind === "error" ? <Icon name="warning" /> : x.kind === "success" ? <Icon name="check" /> : null}
|
||||||
{x.message}
|
{x.message}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+75
-1
@@ -210,6 +210,74 @@ export const de: Messages = {
|
|||||||
|
|
||||||
"session.untitled": "Neue Session",
|
"session.untitled": "Neue Session",
|
||||||
|
|
||||||
|
"wizard.title": "Was möchtest du machen?",
|
||||||
|
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
|
||||||
|
"wizard.whatToDo": "Was möchtest du machen?",
|
||||||
|
"wizard.task.craft": "Etwas craften",
|
||||||
|
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
|
||||||
|
"wizard.task.build": "Am Code arbeiten",
|
||||||
|
"wizard.task.buildDesc": "In einem Projekt umsetzen, reparieren und ausführen",
|
||||||
|
"wizard.task.plan": "Planen",
|
||||||
|
"wizard.task.planDesc": "Nur lesen: analysieren und einen Plan vorschlagen",
|
||||||
|
"wizard.whatToRecreate": "Was möchtest du nachbauen?",
|
||||||
|
"wizard.target.website": "Eine Website",
|
||||||
|
"wizard.target.app": "Eine App",
|
||||||
|
"wizard.target.game": "Ein Spiel",
|
||||||
|
"wizard.target.software": "Alte Software",
|
||||||
|
"wizard.target.fresh": "Nichts – neu anfangen",
|
||||||
|
"wizard.cleanDesc": "Nur aus dem Sichtbaren: Screenshots, sichtbarer Text, Verhalten",
|
||||||
|
"wizard.deepDesc": "Auch Quellen und Binärdateien untersuchen (Ghidra) – nachbilden, nie kopieren",
|
||||||
|
"wizard.softwareNeedsDeep": "Für die Analyse eines Programms braucht es den Deep-Modus (Dekompilieren). Im Clean Room werden nur Screenshots und deine Beschreibung genutzt.",
|
||||||
|
"wizard.sayWhat": "Sag, was du willst",
|
||||||
|
"wizard.placeholder.website": "Besondere Wünsche? z. B. nur die Startseite, in React, dunkles Theme …",
|
||||||
|
"wizard.placeholder.app": "Beschreibe die App, hänge Screenshots an …",
|
||||||
|
"wizard.placeholder.game": "Beschreibe das Spiel, hänge Screenshots an …",
|
||||||
|
"wizard.placeholder.software": "Was ist das für ein Programm? Worauf soll die neue Version laufen? Programm, Archiv oder Installer anhängen.",
|
||||||
|
"wizard.placeholder.fresh": "Zum Beispiel: eine Website für meine Bäckerei",
|
||||||
|
"wizard.placeholder.task": "Was soll gemacht werden?",
|
||||||
|
"wizard.enterHint": "Enter zum Starten · Shift+Enter für neue Zeile · Dateien ablegen oder einfügen",
|
||||||
|
"wizard.softwareHint": "Programm mit Bibliotheken, zip/tar.gz oder Installer anhängen – landet in original/",
|
||||||
|
"wizard.attach": "Dateien anhängen",
|
||||||
|
"wizard.ideas": "Ideen:",
|
||||||
|
"wizard.idea.siteLanding": "Nur die Startseite, als reines HTML/CSS",
|
||||||
|
"wizard.idea.siteShop": "Alle Seiten, in React mit Tailwind",
|
||||||
|
"wizard.idea.appTodo": "Eine To-do-App wie auf meinen Screenshots",
|
||||||
|
"wizard.idea.appNotes": "Eine Notiz-App mit dem gleichen Layout",
|
||||||
|
"wizard.idea.gameSnake": "Ein einfaches Snake-Spiel im Browser",
|
||||||
|
"wizard.idea.gameBreakout": "Ein Breakout-Klon mit eigener Grafik",
|
||||||
|
"wizard.idea.swDos": "Ein altes DOS-Spiel – die neue Version soll im Browser laufen",
|
||||||
|
"wizard.idea.swTool": "Ein altes Windows-Tool – als plattformübergreifende App nachbauen",
|
||||||
|
"wizard.idea.freshBakery": "Eine Website für meine Bäckerei",
|
||||||
|
"wizard.idea.freshSnake": "Ein einfaches Snake-Spiel",
|
||||||
|
"wizard.idea.planFeature": "Ein neues Feature für dieses Projekt planen",
|
||||||
|
"wizard.idea.planRefactor": "Die größten Schwächen finden und ein Refactoring vorschlagen",
|
||||||
|
"wizard.idea.buildTests": "Tests für das Kernmodul schreiben und Fehler beheben",
|
||||||
|
"wizard.idea.buildBug": "Finde und behebe den Fehler: ",
|
||||||
|
"wizard.builtBy": "Gebaut von",
|
||||||
|
"wizard.newFolderIn": "Gespeichert in einem neuen Ordner in",
|
||||||
|
"wizard.existingFolder": "Arbeitet in",
|
||||||
|
"wizard.chooseFolder": "Ordner wählen…",
|
||||||
|
"wizard.useNewFolder": "Neuen Ordner verwenden",
|
||||||
|
"wizard.start": "Start",
|
||||||
|
"wizard.creatingFolder": "Ordner wird angelegt…",
|
||||||
|
"wizard.uploading": "Dateien werden hochgeladen…",
|
||||||
|
"wizard.starting": "Wird gestartet…",
|
||||||
|
"phase.study": "Studieren",
|
||||||
|
"phase.specify": "Spezifizieren",
|
||||||
|
"phase.build": "Bauen",
|
||||||
|
"phase.check": "Prüfen",
|
||||||
|
"phase.ready": "Fertig",
|
||||||
|
"wizard.cloning": "Repository wird geklont…",
|
||||||
|
"wizard.originalTitle": "Original-Software hochladen",
|
||||||
|
"wizard.originalHint": "Programmordner als zip, tar.gz, 7z, Installer oder einzelne Dateien – bis 500 MB pro Datei. Klicken oder hier ablegen.",
|
||||||
|
"wizard.whereLabel": "Wo soll gebaut werden?",
|
||||||
|
"wizard.whereNew": "Neuer Ordner",
|
||||||
|
"wizard.whereExisting": "Bestehender Ordner",
|
||||||
|
"wizard.whereGit": "Git-Repository",
|
||||||
|
"wizard.cloneInto": "Geklont nach",
|
||||||
|
"rename.title": "Session umbenennen",
|
||||||
|
"rename.generateWith": "Namen vorschlagen mit",
|
||||||
|
"rename.generate": "Generieren",
|
||||||
"new.title": "Neue Session",
|
"new.title": "Neue Session",
|
||||||
"new.directory": "Projektverzeichnis",
|
"new.directory": "Projektverzeichnis",
|
||||||
"new.up": "Hoch",
|
"new.up": "Hoch",
|
||||||
@@ -317,7 +385,13 @@ export const de: Messages = {
|
|||||||
"settings.notifications": "Benachrichtigungen",
|
"settings.notifications": "Benachrichtigungen",
|
||||||
"settings.notificationsEnable": "Aktivieren",
|
"settings.notificationsEnable": "Aktivieren",
|
||||||
"settings.notificationsOn": "Aktiv",
|
"settings.notificationsOn": "Aktiv",
|
||||||
"settings.notificationsHint": "Benachrichtigen, wenn ein Agent eine Frage stellt, während die App im Hintergrund ist.",
|
"settings.pushTest": "Test senden",
|
||||||
|
"settings.pushTestSent": "Testbenachrichtigung gesendet",
|
||||||
|
"settings.pushTestNone": "Kein Gerät angemeldet",
|
||||||
|
"settings.pushDisable": "Ausschalten",
|
||||||
|
"settings.pushUnsupported": "Dieser Browser unterstützt keine Push-Benachrichtigungen. Auf iPhone/iPad: CodeCraft zuerst zum Home-Bildschirm hinzufügen.",
|
||||||
|
"settings.pushDenied": "Benachrichtigungen sind für diese Seite blockiert – in den Browser-Einstellungen erlauben.",
|
||||||
|
"settings.notificationsHint": "Push-Benachrichtigungen auf diesem Gerät, wenn ein Agent eine Freigabe braucht, eine Frage stellt oder eine längere Aufgabe beendet – nur wenn CodeCraft nicht geöffnet ist.",
|
||||||
"settings.server": "Server",
|
"settings.server": "Server",
|
||||||
"settings.workspace": "Workspace",
|
"settings.workspace": "Workspace",
|
||||||
"settings.defaultModel": "Standardmodell",
|
"settings.defaultModel": "Standardmodell",
|
||||||
|
|||||||
+75
-1
@@ -209,6 +209,74 @@ export const en = {
|
|||||||
|
|
||||||
"session.untitled": "New session",
|
"session.untitled": "New session",
|
||||||
|
|
||||||
|
"wizard.title": "What do you want to do?",
|
||||||
|
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
|
||||||
|
"wizard.whatToDo": "What do you want to do?",
|
||||||
|
"wizard.task.craft": "Craft something",
|
||||||
|
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
|
||||||
|
"wizard.task.build": "Work on code",
|
||||||
|
"wizard.task.buildDesc": "Implement, fix and run things in a project",
|
||||||
|
"wizard.task.plan": "Plan",
|
||||||
|
"wizard.task.planDesc": "Read-only: analyse and propose a plan",
|
||||||
|
"wizard.whatToRecreate": "What do you want to recreate?",
|
||||||
|
"wizard.target.website": "A website",
|
||||||
|
"wizard.target.app": "An app",
|
||||||
|
"wizard.target.game": "A game",
|
||||||
|
"wizard.target.software": "Old software",
|
||||||
|
"wizard.target.fresh": "Nothing – start fresh",
|
||||||
|
"wizard.cleanDesc": "Only from what you can see: screenshots, visible text, behaviour",
|
||||||
|
"wizard.deepDesc": "Also study sources and binaries (Ghidra) – re-create, never copy",
|
||||||
|
"wizard.softwareNeedsDeep": "Analysing a program needs Deep mode (decompiling). In clean room mode only screenshots and your description are used.",
|
||||||
|
"wizard.sayWhat": "Say what you want",
|
||||||
|
"wizard.placeholder.website": "Anything special? e.g. only the start page, in React, dark theme …",
|
||||||
|
"wizard.placeholder.app": "Describe the app, attach screenshots …",
|
||||||
|
"wizard.placeholder.game": "Describe the game, attach screenshots or a video description …",
|
||||||
|
"wizard.placeholder.software": "What is the program? What should the new version run on? Attach the program, an archive or installer.",
|
||||||
|
"wizard.placeholder.fresh": "For example: a website for my bakery",
|
||||||
|
"wizard.placeholder.task": "What should be done?",
|
||||||
|
"wizard.enterHint": "Enter to start · Shift+Enter for a new line · drop or paste files",
|
||||||
|
"wizard.softwareHint": "Attach the program with its libraries, a zip/tar.gz or an installer – it goes to original/",
|
||||||
|
"wizard.attach": "Attach files",
|
||||||
|
"wizard.ideas": "Ideas:",
|
||||||
|
"wizard.idea.siteLanding": "Only the start page, as plain HTML/CSS",
|
||||||
|
"wizard.idea.siteShop": "All pages, in React with Tailwind",
|
||||||
|
"wizard.idea.appTodo": "A to-do app like in my screenshots",
|
||||||
|
"wizard.idea.appNotes": "A notes app with the same layout",
|
||||||
|
"wizard.idea.gameSnake": "A simple snake game in the browser",
|
||||||
|
"wizard.idea.gameBreakout": "A breakout clone with original graphics",
|
||||||
|
"wizard.idea.swDos": "An old DOS game – the new version should run in the browser",
|
||||||
|
"wizard.idea.swTool": "An old Windows tool – rebuild it as a cross-platform app",
|
||||||
|
"wizard.idea.freshBakery": "A website for my bakery",
|
||||||
|
"wizard.idea.freshSnake": "A simple snake game",
|
||||||
|
"wizard.idea.planFeature": "Plan a new feature for this project",
|
||||||
|
"wizard.idea.planRefactor": "Find the biggest weaknesses and propose a refactoring",
|
||||||
|
"wizard.idea.buildTests": "Add tests for the core module and fix what fails",
|
||||||
|
"wizard.idea.buildBug": "Find and fix the bug: ",
|
||||||
|
"wizard.builtBy": "Built by",
|
||||||
|
"wizard.newFolderIn": "Saved in a new folder in",
|
||||||
|
"wizard.existingFolder": "Works in",
|
||||||
|
"wizard.chooseFolder": "Choose a folder…",
|
||||||
|
"wizard.useNewFolder": "Use a new folder",
|
||||||
|
"wizard.start": "Start",
|
||||||
|
"wizard.creatingFolder": "Creating folder…",
|
||||||
|
"wizard.uploading": "Uploading files…",
|
||||||
|
"wizard.starting": "Starting…",
|
||||||
|
"phase.study": "Study",
|
||||||
|
"phase.specify": "Specify",
|
||||||
|
"phase.build": "Build",
|
||||||
|
"phase.check": "Check",
|
||||||
|
"phase.ready": "Ready",
|
||||||
|
"wizard.cloning": "Cloning repository…",
|
||||||
|
"wizard.originalTitle": "Upload the original software",
|
||||||
|
"wizard.originalHint": "Program folder as zip, tar.gz, 7z, an installer or single files – up to 500 MB each. Click or drop here.",
|
||||||
|
"wizard.whereLabel": "Where should it be built?",
|
||||||
|
"wizard.whereNew": "New folder",
|
||||||
|
"wizard.whereExisting": "Existing folder",
|
||||||
|
"wizard.whereGit": "Git repository",
|
||||||
|
"wizard.cloneInto": "Cloned into",
|
||||||
|
"rename.title": "Rename session",
|
||||||
|
"rename.generateWith": "Suggest a name with",
|
||||||
|
"rename.generate": "Generate",
|
||||||
"new.title": "New session",
|
"new.title": "New session",
|
||||||
"new.directory": "Project directory",
|
"new.directory": "Project directory",
|
||||||
"new.up": "Up",
|
"new.up": "Up",
|
||||||
@@ -316,7 +384,13 @@ export const en = {
|
|||||||
"settings.notifications": "Notifications",
|
"settings.notifications": "Notifications",
|
||||||
"settings.notificationsEnable": "Enable",
|
"settings.notificationsEnable": "Enable",
|
||||||
"settings.notificationsOn": "Enabled",
|
"settings.notificationsOn": "Enabled",
|
||||||
"settings.notificationsHint": "Notify when an agent asks a question while the app is in the background.",
|
"settings.pushTest": "Send test",
|
||||||
|
"settings.pushTestSent": "Test notification sent",
|
||||||
|
"settings.pushTestNone": "No device subscribed",
|
||||||
|
"settings.pushDisable": "Turn off",
|
||||||
|
"settings.pushUnsupported": "This browser does not support push notifications. On iPhone/iPad: add CodeCraft to the home screen first.",
|
||||||
|
"settings.pushDenied": "Notifications are blocked for this site – allow them in the browser settings.",
|
||||||
|
"settings.notificationsHint": "Push notifications on this device when an agent asks for permission, asks a question or finishes a longer task – only while CodeCraft is not open.",
|
||||||
"settings.server": "Server",
|
"settings.server": "Server",
|
||||||
"settings.workspace": "Workspace",
|
"settings.workspace": "Workspace",
|
||||||
"settings.defaultModel": "Default model",
|
"settings.defaultModel": "Default model",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { get, post } from "./api";
|
||||||
|
|
||||||
|
function b64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const s = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((b64.length + 3) % 4));
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(s.length));
|
||||||
|
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||||
|
|
||||||
|
/** Current push subscription of this device (null if none). */
|
||||||
|
export async function currentSubscription(): Promise<PushSubscription | null> {
|
||||||
|
if (!pushSupported()) return null;
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
return reg.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask for permission, subscribe this device and register it with the server. */
|
||||||
|
export async function enablePush(): Promise<void> {
|
||||||
|
if (!pushSupported()) throw new Error("Push is not supported by this browser (on iOS: install the app to the home screen first).");
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
if (perm !== "granted") throw new Error("Notifications were not allowed.");
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const { key } = await get<{ key: string }>("/push/key");
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
// A subscription for another server key must be replaced.
|
||||||
|
if (sub && sub.options.applicationServerKey) {
|
||||||
|
const cur = new Uint8Array(sub.options.applicationServerKey);
|
||||||
|
const want = b64ToBytes(key);
|
||||||
|
if (cur.length !== want.length || cur.some((b, i) => b !== want[i])) {
|
||||||
|
await sub.unsubscribe();
|
||||||
|
sub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(key) });
|
||||||
|
await post("/push/subscribe", sub.toJSON());
|
||||||
|
localStorage.setItem("codecraft.push", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disablePush(): Promise<void> {
|
||||||
|
const sub = await currentSubscription();
|
||||||
|
if (sub) {
|
||||||
|
await fetch("/api/push/subscribe", { method: "DELETE", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: sub.endpoint }) });
|
||||||
|
await sub.unsubscribe();
|
||||||
|
}
|
||||||
|
localStorage.removeItem("codecraft.push");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testPush(): Promise<number> {
|
||||||
|
const r = await post<{ sent: number }>("/push/test", {});
|
||||||
|
return r.sent;
|
||||||
|
}
|
||||||
@@ -197,6 +197,8 @@ export function applyEvent(ev: ServerEvent) {
|
|||||||
/** Notify when the app is in the background and an agent needs input. */
|
/** Notify when the app is in the background and an agent needs input. */
|
||||||
function notify(body: string) {
|
function notify(body: string) {
|
||||||
if (document.visibilityState === "visible" || !("Notification" in window)) return;
|
if (document.visibilityState === "visible" || !("Notification" in window)) return;
|
||||||
|
// With Web Push on this device the server notifies (avoid duplicates).
|
||||||
|
if (localStorage.getItem("codecraft.push") === "1") return;
|
||||||
if (Notification.permission === "granted") {
|
if (Notification.permission === "granted") {
|
||||||
new Notification("CodeCraft", { body, icon: "/icon-192.png" });
|
new Notification("CodeCraft", { body, icon: "/icon-192.png" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2317,3 +2317,480 @@ button.danger-solid {
|
|||||||
.menu-pop-body a:hover {
|
.menu-pop-body a:hover {
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- new session wizard ------------------------------------------------------------ */
|
||||||
|
.wizard {
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
.wiz-title {
|
||||||
|
font-size: 30px;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
color: var(--fg0);
|
||||||
|
}
|
||||||
|
.wiz-sub {
|
||||||
|
color: var(--fg3);
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
.wiz-panel {
|
||||||
|
padding: 22px 24px;
|
||||||
|
}
|
||||||
|
.wiz-step {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.wiz-step h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 19px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--fg0);
|
||||||
|
}
|
||||||
|
.wiz-num {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
background: var(--bg2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wiz-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.wiz-cards.five {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
.wiz-cards.two {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.wiz-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 64px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--fg1);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.wiz-card:hover {
|
||||||
|
border-color: var(--fg3);
|
||||||
|
}
|
||||||
|
.wiz-card.active {
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
padding: 13px 15px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg0));
|
||||||
|
}
|
||||||
|
.wiz-card-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
.wiz-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg0);
|
||||||
|
}
|
||||||
|
.wiz-card-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg3);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.wiz-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.wiz-card .wiz-icon {
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.wiz-card.active .wiz-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiz-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.wiz-url {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.wiz-input {
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg0);
|
||||||
|
padding: 12px 14px 8px;
|
||||||
|
}
|
||||||
|
.wiz-input:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiz-input textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
resize: vertical;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.wiz-input-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.wiz-input-foot .wiz-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.wiz-ideas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.chip-btn {
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--fg1);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiz-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
border-top: 1px solid var(--bg2);
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
.wiz-foot-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wiz-foot-row label {
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.wiz-folder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.wiz-folder .wiz-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.wiz-folder-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wiz-folder-name {
|
||||||
|
width: 220px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--blue);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.wiz-picker .dir-list button .wiz-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.wiz-start-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.wiz-start {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 26px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.wiz-start .wiz-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.wiz-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.wiz-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.wiz-cards,
|
||||||
|
.wiz-cards.five,
|
||||||
|
.wiz-cards.two {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.wiz-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.wiz-card-desc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- craft phase bar ----------------------------------------------------------------- */
|
||||||
|
.phase-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--bg2);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.phase-bar .phase {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--fg3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.phase-bar .phase .dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--bg3);
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.phase-bar .phase.done {
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.phase-bar .phase.done .dot {
|
||||||
|
background: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
}
|
||||||
|
.phase-bar .phase.done .dot svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--bg0);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.phase-bar .phase.current {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.phase-bar .phase.current .dot {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
animation: glow 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.phase-bar .sep {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 18px;
|
||||||
|
max-width: 60px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: var(--bg3);
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.phase-bar .sep.done {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiz-drop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 2px dashed var(--bg3);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
|
.wiz-drop:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiz-drop .wiz-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiz-originals {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.wiz-original {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg1);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.wiz-original .name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wiz-original .size {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.wiz-original .bar {
|
||||||
|
width: 120px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wiz-original .bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
.wiz-folder-modes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wiz-folder-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--fg1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wiz-folder-mode .wiz-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.wiz-folder-mode.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--bg0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- icons (SVG, coloured by the theme via currentColor) --------------------- */
|
||||||
|
.icon {
|
||||||
|
width: 1.15em;
|
||||||
|
height: 1.15em;
|
||||||
|
vertical-align: -0.2em;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon .fill {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
.icon.tiny {
|
||||||
|
width: 0.85em;
|
||||||
|
height: 0.85em;
|
||||||
|
vertical-align: -0.08em;
|
||||||
|
}
|
||||||
|
.icon.ok {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.icon.ficon {
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.icon.ficon.dir {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.icon.nav-icon {
|
||||||
|
width: 1.15em;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.file-chip .icon.file-chip-icon {
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.theme-toggle .icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-edit {
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.topbar h1:hover .title-edit,
|
||||||
|
.title-edit:focus-visible {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.title-edit {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rename-gen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.rename-gen button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ export interface Session {
|
|||||||
owner_id?: string | null;
|
owner_id?: string | null;
|
||||||
/** Craft: clean room (true) or deep (false); undefined = server default. */
|
/** Craft: clean room (true) or deep (false); undefined = server default. */
|
||||||
clean_room?: boolean | null;
|
clean_room?: boolean | null;
|
||||||
|
/** Craft workflow phase reported by the agent. */
|
||||||
|
phase?: string;
|
||||||
/** Listed because it was shared with the current user. */
|
/** Listed because it was shared with the current user. */
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default defineConfig({
|
|||||||
runtimeCaching: [],
|
runtimeCaching: [],
|
||||||
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
|
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
|
||||||
globIgnores: ["**/occt-import-js*"],
|
globIgnores: ["**/occt-import-js*"],
|
||||||
|
// Web Push handlers (push, notificationclick).
|
||||||
|
importScripts: ["push-sw.js"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user