Compare commits
No commits in common. "eba75ee359c67c2dd19476cd36516bf13f2cabb5" and "05909dbde7797b858f9e79a02983d80c4fc79ade" have entirely different histories.
eba75ee359
...
05909dbde7
272
Cargo.lock
generated
272
Cargo.lock
generated
@ -68,7 +68,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -87,7 +87,7 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http-range",
|
"http-range",
|
||||||
"log",
|
"log",
|
||||||
@ -113,7 +113,7 @@ dependencies = [
|
|||||||
"brotli",
|
"brotli",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytestring",
|
"bytestring",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
@ -147,44 +147,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "actix-multipart"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53"
|
|
||||||
dependencies = [
|
|
||||||
"actix-multipart-derive",
|
|
||||||
"actix-utils",
|
|
||||||
"actix-web",
|
|
||||||
"derive_more 0.99.20",
|
|
||||||
"futures-core",
|
|
||||||
"futures-util",
|
|
||||||
"httparse",
|
|
||||||
"local-waker",
|
|
||||||
"log",
|
|
||||||
"memchr",
|
|
||||||
"mime",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_plain",
|
|
||||||
"tempfile",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "actix-multipart-derive"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b"
|
|
||||||
dependencies = [
|
|
||||||
"darling",
|
|
||||||
"parse-size",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-router"
|
name = "actix-router"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@ -266,7 +228,7 @@ dependencies = [
|
|||||||
"bytestring",
|
"bytestring",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cookie",
|
"cookie",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -407,16 +369,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aes-keywrap"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522"
|
|
||||||
dependencies = [
|
|
||||||
"aes 0.9.0-rc.4",
|
|
||||||
"byteorder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent"
|
name = "agent"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@ -604,7 +556,6 @@ version = "0.2.9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
"actix-multipart",
|
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-ws",
|
"actix-ws",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@ -615,7 +566,6 @@ dependencies = [
|
|||||||
"email",
|
"email",
|
||||||
"frontend",
|
"frontend",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
|
||||||
"git",
|
"git",
|
||||||
"mime_guess2",
|
"mime_guess2",
|
||||||
"models",
|
"models",
|
||||||
@ -722,12 +672,6 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayref"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@ -1197,12 +1141,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "binstring"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@ -1263,17 +1201,6 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "blake2b_simd"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3"
|
|
||||||
dependencies = [
|
|
||||||
"arrayref",
|
|
||||||
"arrayvec",
|
|
||||||
"constant_time_eq",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@ -1611,17 +1538,6 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "de0758edba32d61d1fd9f4d69491b47604b91ee2f7e6b33de7e54ca4ebe55dc3"
|
checksum = "de0758edba32d61d1fd9f4d69491b47604b91ee2f7e6b33de7e54ca4ebe55dc3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "coarsetime"
|
|
||||||
version = "0.1.37"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"wasix",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codepage"
|
name = "codepage"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -1715,12 +1631,6 @@ version = "0.4.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "convert_case"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@ -1818,9 +1728,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.3.0"
|
version = "3.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc-catalog",
|
"crc-catalog",
|
||||||
]
|
]
|
||||||
@ -1948,12 +1858,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ct-codecs"
|
|
||||||
version = "1.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@ -2186,19 +2090,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "derive_more"
|
|
||||||
version = "0.99.20"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
|
||||||
dependencies = [
|
|
||||||
"convert_case 0.4.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"rustc_version",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@ -2312,17 +2203,6 @@ dependencies = [
|
|||||||
"spki",
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ece-native"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "30d8e2c05464ca407a32663c1f119abd2a0f8d948879c160fc6cf5b86b6c05d6"
|
|
||||||
dependencies = [
|
|
||||||
"aes-gcm 0.10.3",
|
|
||||||
"hkdf",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "2.2.3"
|
version = "2.2.3"
|
||||||
@ -2333,16 +2213,6 @@ dependencies = [
|
|||||||
"signature 2.2.0",
|
"signature 2.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ed25519-compact"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd"
|
|
||||||
dependencies = [
|
|
||||||
"ct-codecs",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519-dalek"
|
name = "ed25519-dalek"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@ -2752,7 +2622,6 @@ name = "frontend"
|
|||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"md5",
|
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -3398,30 +3267,6 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac-sha1-compact"
|
|
||||||
version = "1.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac-sha256"
|
|
||||||
version = "1.1.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
|
||||||
dependencies = [
|
|
||||||
"digest 0.10.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hmac-sha512"
|
|
||||||
version = "1.1.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b"
|
|
||||||
dependencies = [
|
|
||||||
"digest 0.10.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "home"
|
name = "home"
|
||||||
version = "0.5.12"
|
version = "0.5.12"
|
||||||
@ -4058,46 +3903,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jwt-simple"
|
|
||||||
version = "0.12.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3991f54af4b009bb6efe01aa5a4fcce9ca52f3de7a104a3f6b6e2ad36c852c48"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"binstring",
|
|
||||||
"blake2b_simd",
|
|
||||||
"coarsetime",
|
|
||||||
"ct-codecs",
|
|
||||||
"ed25519-compact",
|
|
||||||
"hmac-sha1-compact",
|
|
||||||
"hmac-sha256",
|
|
||||||
"hmac-sha512",
|
|
||||||
"k256",
|
|
||||||
"p256",
|
|
||||||
"p384",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"superboring",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "k256"
|
|
||||||
version = "0.13.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"ecdsa",
|
|
||||||
"elliptic-curve",
|
|
||||||
"once_cell",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"signature 2.2.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k8s-openapi"
|
name = "k8s-openapi"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@ -5228,12 +5033,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parse-size"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "password-hash"
|
name = "password-hash"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@ -6798,7 +6597,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bigdecimal",
|
"bigdecimal",
|
||||||
"chrono",
|
"chrono",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
@ -7094,15 +6893,6 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_plain"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@ -7138,7 +6928,6 @@ dependencies = [
|
|||||||
"async-openai",
|
"async-openai",
|
||||||
"avatar",
|
"avatar",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"base64ct",
|
|
||||||
"calamine",
|
"calamine",
|
||||||
"captcha-rs",
|
"captcha-rs",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -7153,12 +6942,9 @@ dependencies = [
|
|||||||
"git2",
|
"git2",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"http 1.4.0",
|
|
||||||
"jwt-simple",
|
|
||||||
"lopdf",
|
"lopdf",
|
||||||
"models",
|
"models",
|
||||||
"moka",
|
"moka",
|
||||||
"p256",
|
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"queue",
|
"queue",
|
||||||
"quick-xml 0.37.5",
|
"quick-xml 0.37.5",
|
||||||
@ -7184,7 +6970,6 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"web-push-native",
|
|
||||||
"zip 8.4.0",
|
"zip 8.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -7197,7 +6982,7 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"deadpool-redis",
|
"deadpool-redis",
|
||||||
"derive_more 2.1.1",
|
"derive_more",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
@ -7765,21 +7550,6 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "superboring"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d8af9125d1ea290cf5c297b94d0e518c939bbe1f45ef130c19525dae7afba99"
|
|
||||||
dependencies = [
|
|
||||||
"aes-gcm 0.10.3",
|
|
||||||
"aes-keywrap",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"hmac-sha256",
|
|
||||||
"hmac-sha512",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"rsa",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@ -8572,15 +8342,6 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasix"
|
|
||||||
version = "0.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332"
|
|
||||||
dependencies = [
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.115"
|
version = "0.2.115"
|
||||||
@ -8684,23 +8445,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-push-native"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2175ef28a9a693fa88f322d88484f702a340da864a449a2b6b2a1fe26db712f3"
|
|
||||||
dependencies = [
|
|
||||||
"aes-gcm 0.10.3",
|
|
||||||
"base64ct",
|
|
||||||
"ece-native",
|
|
||||||
"hkdf",
|
|
||||||
"http 1.4.0",
|
|
||||||
"jwt-simple",
|
|
||||||
"p256",
|
|
||||||
"serde",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.92"
|
version = "0.3.92"
|
||||||
|
|||||||
@ -106,8 +106,7 @@ sha1_smol = "1.0.1"
|
|||||||
rsa = { version = "0.9.7", package = "rsa" }
|
rsa = { version = "0.9.7", package = "rsa" }
|
||||||
reqwest = { version = "0.13.2", default-features = false }
|
reqwest = { version = "0.13.2", default-features = false }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
# aws-lc-sys requires NASM on Windows, so we use local filesystem storage instead of S3
|
aws-sdk-s3 = "1.127.0"
|
||||||
# aws-sdk-s3 = "1.127.0"
|
|
||||||
sea-orm = "2.0.0-rc.37"
|
sea-orm = "2.0.0-rc.37"
|
||||||
sea-orm-migration = "2.0.0-rc.37"
|
sea-orm-migration = "2.0.0-rc.37"
|
||||||
sha1 = { version = "0.10.6", features = ["compress"] }
|
sha1 = { version = "0.10.6", features = ["compress"] }
|
||||||
@ -150,7 +149,6 @@ pulldown-cmark = "0.12"
|
|||||||
quick-xml = "0.37"
|
quick-xml = "0.37"
|
||||||
sqlparser = "0.55"
|
sqlparser = "0.55"
|
||||||
lazy_static = "1.5"
|
lazy_static = "1.5"
|
||||||
md5 = "0.7"
|
|
||||||
moka = "0.12.15"
|
moka = "0.12.15"
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
@ -159,9 +157,6 @@ serde_bytes = "0.11.19"
|
|||||||
phf = "0.13.1"
|
phf = "0.13.1"
|
||||||
phf_codegen = "0.13.1"
|
phf_codegen = "0.13.1"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
base64ct = "1"
|
|
||||||
p256 = { version = "0.13", features = ["ecdsa", "std"] }
|
|
||||||
http = "1"
|
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
serverExternalPackages: ["bcrypt", "ioredis"],
|
serverExternalPackages: ["bcrypt"],
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,20 +35,16 @@ function createClusterClient(): Redis {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const firstUrl = new URL(REDIS_CLUSTER_URLS[0]);
|
const firstUrl = new URL(REDIS_CLUSTER_URLS[0]);
|
||||||
const username = firstUrl.username || undefined;
|
|
||||||
const password = firstUrl.password || undefined;
|
|
||||||
|
|
||||||
// ioredis 5.x: username/password 必须放在 redisOptions 里,不能放顶层
|
// ioredis 5.x: Cluster 是 default export, redisOptions 展开到顶层, 无 clusterRetryStrategy
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const cluster = new (Cluster as any)(nodes, {
|
const cluster = new (Cluster as any)(nodes, {
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
enableReadyCheck: true,
|
|
||||||
maxRetriesPerRequest: 3,
|
maxRetriesPerRequest: 3,
|
||||||
redisOptions: {
|
retryStrategy: (times: number) => Math.min(times * 100, 3000),
|
||||||
username,
|
// 从第一个 URL 提取认证信息(所有节点共用相同密码)
|
||||||
password,
|
username: firstUrl.username || undefined,
|
||||||
retryStrategy: (times: number) => Math.min(times * 100, 3000),
|
password: firstUrl.password || undefined,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return cluster as Redis;
|
return cluster as Redis;
|
||||||
@ -56,16 +52,6 @@ function createClusterClient(): Redis {
|
|||||||
|
|
||||||
export function getRedis(): Redis {
|
export function getRedis(): Redis {
|
||||||
if (!redis) {
|
if (!redis) {
|
||||||
const mode = REDIS_CLUSTER_URLS.length > 1 ? "cluster" : "single";
|
|
||||||
const clusterNodes = REDIS_CLUSTER_URLS.map((url) => {
|
|
||||||
const u = new URL(url);
|
|
||||||
return `${u.hostname}:${u.port}`;
|
|
||||||
});
|
|
||||||
console.log("[Redis] Initializing", {
|
|
||||||
mode,
|
|
||||||
clusterNodes,
|
|
||||||
singleUrl: mode === "single" ? REDIS_URL : undefined,
|
|
||||||
});
|
|
||||||
redis =
|
redis =
|
||||||
REDIS_CLUSTER_URLS.length > 1 ? createClusterClient() : createSingleClient();
|
REDIS_CLUSTER_URLS.length > 1 ? createClusterClient() : createSingleClient();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,12 +42,10 @@ actix-ws = { workspace = true, features = [] }
|
|||||||
actix = { workspace = true, features = ["macros"] }
|
actix = { workspace = true, features = ["macros"] }
|
||||||
tokio-stream = { workspace = true, features = ["sync"] }
|
tokio-stream = { workspace = true, features = ["sync"] }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
|
||||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
mime_guess2 = { workspace = true, features = ["phf-map"] }
|
mime_guess2 = { workspace = true, features = ["phf-map"] }
|
||||||
sea-orm = "2.0.0-rc.37"
|
sea-orm = "2.0.0-rc.37"
|
||||||
rust_decimal = "1.40.0"
|
rust_decimal = "1.40.0"
|
||||||
actix-multipart = { workspace = true, features = ["tempfile"] }
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@ -1,69 +1,29 @@
|
|||||||
use actix_web::{http::header, web, HttpRequest, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use mime_guess2::MimeGuess;
|
use mime_guess2::MimeGuess;
|
||||||
|
|
||||||
fn cache_control_header(path: &str) -> &'static str {
|
pub async fn serve_frontend(path: web::Path<String>) -> HttpResponse {
|
||||||
if path == "index.html" {
|
|
||||||
"no-cache, no-store, must-revalidate"
|
|
||||||
} else if path.ends_with(".js")
|
|
||||||
|| path.ends_with(".css")
|
|
||||||
|| path.ends_with(".woff2")
|
|
||||||
|| path.ends_with(".woff")
|
|
||||||
|| path.ends_with(".ttf")
|
|
||||||
|| path.ends_with(".otf")
|
|
||||||
|| path.ends_with(".png")
|
|
||||||
|| path.ends_with(".jpg")
|
|
||||||
|| path.ends_with(".jpeg")
|
|
||||||
|| path.ends_with(".gif")
|
|
||||||
|| path.ends_with(".svg")
|
|
||||||
|| path.ends_with(".ico")
|
|
||||||
|| path.ends_with(".webp")
|
|
||||||
|| path.ends_with(".avif")
|
|
||||||
|| path.ends_with(".map")
|
|
||||||
{
|
|
||||||
"public, max-age=31536000, immutable"
|
|
||||||
} else {
|
|
||||||
"no-cache"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn serve_frontend(req: HttpRequest, path: web::Path<String>) -> HttpResponse {
|
|
||||||
let path = path.into_inner();
|
let path = path.into_inner();
|
||||||
let path_str = if path.is_empty() || path == "/" {
|
let path = if path.is_empty() || path == "/" {
|
||||||
"index.html"
|
"index.html"
|
||||||
} else {
|
} else {
|
||||||
path.as_str()
|
&path
|
||||||
};
|
};
|
||||||
|
|
||||||
let cc = cache_control_header(path_str);
|
match frontend::get_frontend_asset(path) {
|
||||||
|
Some(data) => {
|
||||||
match frontend::get_frontend_asset_with_etag(path_str) {
|
let mime = MimeGuess::from_path(path).first_or_octet_stream();
|
||||||
Some((data, etag)) => {
|
|
||||||
// Check If-None-Match for conditional request
|
|
||||||
if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) {
|
|
||||||
if let Ok(client_etag) = if_none_match.to_str() {
|
|
||||||
if client_etag == etag {
|
|
||||||
return HttpResponse::NotModified()
|
|
||||||
.insert_header(("Cache-Control", cc))
|
|
||||||
.insert_header(("ETag", etag))
|
|
||||||
.finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mime = MimeGuess::from_path(path_str).first_or_octet_stream();
|
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.content_type(mime.as_ref())
|
.content_type(mime.as_ref())
|
||||||
.insert_header(("Cache-Control", cc))
|
|
||||||
.insert_header(("ETag", etag))
|
|
||||||
.body(data.to_vec())
|
.body(data.to_vec())
|
||||||
}
|
}
|
||||||
None => match frontend::get_frontend_asset_with_etag("index.html") {
|
None => {
|
||||||
Some((data, etag)) => HttpResponse::Ok()
|
// Fallback to index.html for SPA routing
|
||||||
.content_type("text/html")
|
match frontend::get_frontend_asset("index.html") {
|
||||||
.insert_header(("Cache-Control", "no-cache, no-store, must-revalidate"))
|
Some(data) => HttpResponse::Ok()
|
||||||
.insert_header(("ETag", etag))
|
.content_type("text/html")
|
||||||
.body(data.to_vec()),
|
.body(data.to_vec()),
|
||||||
None => HttpResponse::NotFound().finish(),
|
None => HttpResponse::NotFound().finish(),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ pub mod pin;
|
|||||||
pub mod reaction;
|
pub mod reaction;
|
||||||
pub mod room;
|
pub mod room;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
pub mod upload;
|
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
pub mod ws_handler;
|
pub mod ws_handler;
|
||||||
pub mod ws_types;
|
pub mod ws_types;
|
||||||
@ -174,11 +173,6 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(
|
.route(
|
||||||
"/me/notifications/{notification_id}/archive",
|
"/me/notifications/{notification_id}/archive",
|
||||||
web::post().to(notification::notification_archive),
|
web::post().to(notification::notification_archive),
|
||||||
)
|
|
||||||
// file upload
|
|
||||||
.route(
|
|
||||||
"/rooms/{room_id}/upload",
|
|
||||||
web::post().to(upload::upload),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
use actix_multipart::Multipart;
|
|
||||||
use actix_web::{HttpResponse, Result, web};
|
|
||||||
use actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use service::AppService;
|
|
||||||
use session::Session;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct UploadResponse {
|
|
||||||
pub url: String,
|
|
||||||
pub file_name: String,
|
|
||||||
pub file_size: i64,
|
|
||||||
pub content_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_key(key: &str) -> String {
|
|
||||||
key.replace(['/', '\\', ':'], "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_filename(disposition: &actix_web::http::header::HeaderValue) -> Option<String> {
|
|
||||||
let s = disposition.to_str().ok()?;
|
|
||||||
// Simple parsing: extract filename from Content-Disposition header
|
|
||||||
// e.g., "form-data; filename="test.png""
|
|
||||||
for part in s.split(';') {
|
|
||||||
let part = part.trim();
|
|
||||||
if part.starts_with("filename=") {
|
|
||||||
let value = &part[9..].trim_matches('"');
|
|
||||||
if !value.is_empty() {
|
|
||||||
return Some(value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upload(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
path: web::Path<Uuid>,
|
|
||||||
mut payload: Multipart,
|
|
||||||
) -> Result<HttpResponse, crate::error::ApiError> {
|
|
||||||
let user_id = session
|
|
||||||
.user()
|
|
||||||
.ok_or_else(|| crate::error::ApiError(service::error::AppError::Unauthorized))?;
|
|
||||||
|
|
||||||
let storage = service
|
|
||||||
.storage
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
crate::error::ApiError(service::error::AppError::BadRequest(
|
|
||||||
"Storage not configured".to_string(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let room_id = path.into_inner();
|
|
||||||
service
|
|
||||||
.room
|
|
||||||
.require_room_member(room_id, user_id)
|
|
||||||
.await
|
|
||||||
.map_err(crate::error::ApiError::from)?;
|
|
||||||
|
|
||||||
let max_size = service.config.storage_max_file_size();
|
|
||||||
|
|
||||||
let mut file_data: Vec<u8> = Vec::new();
|
|
||||||
let mut file_name = String::new();
|
|
||||||
let mut content_type = "application/octet-stream".to_string();
|
|
||||||
|
|
||||||
while let Some(item) = payload.next().await {
|
|
||||||
let mut field = item.map_err(|e| {
|
|
||||||
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(disposition) = field.headers().get(&CONTENT_DISPOSITION) {
|
|
||||||
if let Some(name) = extract_filename(disposition) {
|
|
||||||
file_name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ct) = field.headers().get(&CONTENT_TYPE) {
|
|
||||||
if let Ok(ct_str) = ct.to_str() {
|
|
||||||
if !ct_str.is_empty() && ct_str != "application/octet-stream" {
|
|
||||||
content_type = ct_str.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some(chunk) = field.next().await {
|
|
||||||
let data = chunk.map_err(|e| {
|
|
||||||
crate::error::ApiError(service::error::AppError::BadRequest(e.to_string()))
|
|
||||||
})?;
|
|
||||||
if file_data.len() + data.len() > max_size {
|
|
||||||
return Err(crate::error::ApiError(
|
|
||||||
service::error::AppError::BadRequest(format!(
|
|
||||||
"File exceeds maximum size of {} bytes",
|
|
||||||
max_size
|
|
||||||
)),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
file_data.extend_from_slice(&data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if file_data.is_empty() {
|
|
||||||
return Err(crate::error::ApiError(
|
|
||||||
service::error::AppError::BadRequest("No file provided".to_string()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if file_name.is_empty() {
|
|
||||||
file_name = format!("upload_{}", Uuid::now_v7());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect content type from file extension if still octet-stream
|
|
||||||
if content_type == "application/octet-stream" {
|
|
||||||
content_type = mime_guess2::from_path(&file_name)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let unique_name = format!("{}_{}", Uuid::now_v7(), sanitize_key(&file_name));
|
|
||||||
let key = format!("rooms/{}/{}", room_id, unique_name);
|
|
||||||
let file_size = file_data.len() as i64;
|
|
||||||
|
|
||||||
let url = storage
|
|
||||||
.upload(&key, file_data)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
crate::error::ApiError(service::error::AppError::InternalServerError(
|
|
||||||
e.to_string(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(crate::ApiResponse::ok(UploadResponse {
|
|
||||||
url,
|
|
||||||
file_name,
|
|
||||||
file_size,
|
|
||||||
content_type,
|
|
||||||
})
|
|
||||||
.to_response())
|
|
||||||
}
|
|
||||||
@ -98,8 +98,6 @@ pub struct AiStreamChunkPayload {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub done: bool,
|
pub done: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// Human-readable AI model name for display in the UI.
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RoomMessageStreamChunkEvent> for AiStreamChunkPayload {
|
impl From<RoomMessageStreamChunkEvent> for AiStreamChunkPayload {
|
||||||
@ -110,7 +108,6 @@ impl From<RoomMessageStreamChunkEvent> for AiStreamChunkPayload {
|
|||||||
content: e.content,
|
content: e.content,
|
||||||
done: e.done,
|
done: e.done,
|
||||||
error: e.error,
|
error: e.error,
|
||||||
display_name: e.display_name,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,14 +55,6 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
"/me/notifications/preferences",
|
"/me/notifications/preferences",
|
||||||
web::patch().to(notification::update_notification_preferences),
|
web::patch().to(notification::update_notification_preferences),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/me/notifications/push/vapid-key",
|
|
||||||
web::get().to(notification::get_vapid_public_key),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/me/notifications/push/subscription",
|
|
||||||
web::delete().to(notification::unsubscribe_push),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/me/heatmap",
|
"/me/heatmap",
|
||||||
web::get().to(chpc::get_my_contribution_heatmap),
|
web::get().to(chpc::get_my_contribution_heatmap),
|
||||||
|
|||||||
@ -1,57 +1,8 @@
|
|||||||
|
use crate::{ApiResponse, error::ApiError};
|
||||||
use actix_web::{HttpResponse, Result, web};
|
use actix_web::{HttpResponse, Result, web};
|
||||||
use service::error::AppError;
|
|
||||||
use service::AppService;
|
use service::AppService;
|
||||||
use session::Session;
|
use session::Session;
|
||||||
|
|
||||||
use crate::{error::ApiError, ApiResponse};
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct VapidKeyResponse {
|
|
||||||
pub public_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/users/me/notifications/push/vapid-key",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Get VAPID public key for push subscription", body = ApiResponse<VapidKeyResponse>),
|
|
||||||
(status = 503, description = "Push notifications not configured"),
|
|
||||||
),
|
|
||||||
tag = "User"
|
|
||||||
)]
|
|
||||||
pub async fn get_vapid_public_key(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
let public_key = service
|
|
||||||
.config
|
|
||||||
.vapid_public_key();
|
|
||||||
let public_key = match public_key {
|
|
||||||
Some(k) => k,
|
|
||||||
None => {
|
|
||||||
let err: AppError = AppError::InternalError;
|
|
||||||
return Err(err.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(ApiResponse::ok(VapidKeyResponse { public_key }).to_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/api/users/me/notifications/push/subscription",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Unsubscribe from push notifications", body = ApiResponse<serde_json::Value>),
|
|
||||||
(status = 401, description = "Unauthorized"),
|
|
||||||
),
|
|
||||||
tag = "User"
|
|
||||||
)]
|
|
||||||
pub async fn unsubscribe_push(
|
|
||||||
service: web::Data<AppService>,
|
|
||||||
session: Session,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
service.user_unsubscribe_push(&session).await?;
|
|
||||||
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/users/me/notifications/preferences",
|
path = "/api/users/me/notifications/preferences",
|
||||||
|
|||||||
@ -22,8 +22,7 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Environment variables (e.g. K8s injected APP_DOMAIN_URL) take precedence over .env files
|
env = env.into_iter().chain(std::env::vars()).collect();
|
||||||
env = std::env::vars().chain(env).collect();
|
|
||||||
let this = AppConfig { env };
|
let this = AppConfig { env };
|
||||||
if let Err(config) = GLOBAL_CONFIG.set(this) {
|
if let Err(config) = GLOBAL_CONFIG.set(this) {
|
||||||
eprintln!("Failed to set global config: {:?}", config);
|
eprintln!("Failed to set global config: {:?}", config);
|
||||||
@ -48,4 +47,3 @@ pub mod qdrant;
|
|||||||
pub mod redis;
|
pub mod redis;
|
||||||
pub mod smtp;
|
pub mod smtp;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
pub mod storage;
|
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
use crate::AppConfig;
|
|
||||||
|
|
||||||
impl AppConfig {
|
|
||||||
pub fn storage_path(&self) -> String {
|
|
||||||
self.env
|
|
||||||
.get("STORAGE_PATH")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "/data/files".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn storage_public_url(&self) -> String {
|
|
||||||
self.env
|
|
||||||
.get("STORAGE_PUBLIC_URL")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "/files".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn storage_max_file_size(&self) -> usize {
|
|
||||||
self.env
|
|
||||||
.get("STORAGE_MAX_FILE_SIZE")
|
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
|
||||||
.unwrap_or(10 * 1024 * 1024) // 10MB default
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn vapid_public_key(&self) -> Option<String> {
|
|
||||||
self.env.get("VAPID_PUBLIC_KEY").cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn vapid_private_key(&self) -> Option<String> {
|
|
||||||
self.env.get("VAPID_PRIVATE_KEY").cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn vapid_sender_email(&self) -> String {
|
|
||||||
self.env
|
|
||||||
.get("VAPID_SENDER_EMAIL")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "mailto:admin@example.com".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,4 +8,3 @@ lazy_static.workspace = true
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
md5.workspace = true
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
use md5::compute as md5_hash;
|
|
||||||
use std::{env, fs, path::PathBuf, process::Command};
|
use std::{env, fs, path::PathBuf, process::Command};
|
||||||
|
|
||||||
fn run_pnpm(args: &[&str], cwd: &str) {
|
fn run_pnpm(args: &[&str], cwd: &str) {
|
||||||
@ -67,24 +66,15 @@ fn main() {
|
|||||||
let safe_name = key.replace('/', "_").replace('\\', "_");
|
let safe_name = key.replace('/', "_").replace('\\', "_");
|
||||||
let blob_path = blob_dir.join(&safe_name);
|
let blob_path = blob_dir.join(&safe_name);
|
||||||
fs::copy(&file, &blob_path).unwrap();
|
fs::copy(&file, &blob_path).unwrap();
|
||||||
|
|
||||||
// Compute ETag (MD5 hex of content) at build time
|
|
||||||
let content = fs::read(&file).unwrap();
|
|
||||||
let hash = md5_hash(&content);
|
|
||||||
let etag_literal = format!("\"{:x}\"", hash);
|
|
||||||
|
|
||||||
let key_literal = format!("\"{}\"", key.replace('"', "\\\""));
|
let key_literal = format!("\"{}\"", key.replace('"', "\\\""));
|
||||||
strs.push(format!(
|
strs.push(format!(" ({}, include_bytes!(\"dist_blobs/{}\")),", key_literal, safe_name));
|
||||||
" ({}, include_bytes!(\"dist_blobs/{}\"), {}),",
|
|
||||||
key_literal, safe_name, etag_literal
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let out_file = out_dir.join("frontend.rs");
|
let out_file = out_dir.join("frontend.rs");
|
||||||
let generated = format!(
|
let content = format!(
|
||||||
"lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8], &'static str)> = vec![\n{} ];\n}}\n",
|
"lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8])> = vec![\n{} ];\n}}\n",
|
||||||
strs.join("\n")
|
strs.join("\n")
|
||||||
);
|
);
|
||||||
fs::write(&out_file, generated).unwrap();
|
fs::write(&out_file, content).unwrap();
|
||||||
println!("cargo:include={}", out_file.display());
|
println!("cargo:include={}", out_file.display());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,5 @@ include!(concat!(env!("OUT_DIR"), "/frontend.rs"));
|
|||||||
|
|
||||||
/// Returns the embedded frontend static asset for the given path, or `None` if not found.
|
/// Returns the embedded frontend static asset for the given path, or `None` if not found.
|
||||||
pub fn get_frontend_asset(path: &str) -> Option<&'static [u8]> {
|
pub fn get_frontend_asset(path: &str) -> Option<&'static [u8]> {
|
||||||
FRONTEND.iter().find(|(k, _, _)| *k == path).map(|(_, v, _)| *v)
|
FRONTEND.iter().find(|(k, _)| *k == path).map(|(_, v)| *v)
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the embedded frontend static asset and its ETag for the given path.
|
|
||||||
pub fn get_frontend_asset_with_etag(path: &str) -> Option<(&'static [u8], &'static str)> {
|
|
||||||
FRONTEND.iter()
|
|
||||||
.find(|(k, _, _)| *k == path)
|
|
||||||
.map(|(_, v, etag)| (v as &_, etag as &_))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,9 +82,6 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260415_000001_add_issue_id_to_agent_task::Migration),
|
Box::new(m20260415_000001_add_issue_id_to_agent_task::Migration),
|
||||||
Box::new(m20260416_000001_add_retry_count_to_agent_task::Migration),
|
Box::new(m20260416_000001_add_retry_count_to_agent_task::Migration),
|
||||||
Box::new(m20260417_000001_add_stream_to_room_ai::Migration),
|
Box::new(m20260417_000001_add_stream_to_room_ai::Migration),
|
||||||
Box::new(m20260420_000001_create_room_attachment::Migration),
|
|
||||||
Box::new(m20260420_000002_add_push_subscription::Migration),
|
|
||||||
Box::new(m20260420_000003_add_model_id_to_room_message::Migration),
|
|
||||||
// Repo tables
|
// Repo tables
|
||||||
Box::new(m20250628_000028_create_repo::Migration),
|
Box::new(m20250628_000028_create_repo::Migration),
|
||||||
Box::new(m20250628_000029_create_repo_branch::Migration),
|
Box::new(m20250628_000029_create_repo_branch::Migration),
|
||||||
@ -257,5 +254,3 @@ pub mod m20260414_000001_create_agent_task;
|
|||||||
pub mod m20260415_000001_add_issue_id_to_agent_task;
|
pub mod m20260415_000001_add_issue_id_to_agent_task;
|
||||||
pub mod m20260416_000001_add_retry_count_to_agent_task;
|
pub mod m20260416_000001_add_retry_count_to_agent_task;
|
||||||
pub mod m20260417_000001_add_stream_to_room_ai;
|
pub mod m20260417_000001_add_stream_to_room_ai;
|
||||||
pub mod m20260420_000001_create_room_attachment;
|
|
||||||
pub mod m20260420_000002_add_push_subscription;
|
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
//! SeaORM migration: create room_attachment table
|
|
||||||
|
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
impl MigrationName for Migration {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"m20260420_000001_create_room_attachment"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
let sql = include_str!("sql/m20260420_000001_create_room_attachment.sql");
|
|
||||||
super::execute_sql(manager, sql).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.get_connection()
|
|
||||||
.execute_raw(sea_orm::Statement::from_string(
|
|
||||||
sea_orm::DbBackend::Postgres,
|
|
||||||
"DROP TABLE IF EXISTS room_attachment;".to_string(),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
//! SeaORM migration: add push subscription fields to user_notification
|
|
||||||
|
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
impl MigrationName for Migration {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"m20260420_000002_add_push_subscription"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
let sql = include_str!("sql/m20260420_000002_add_push_subscription.sql");
|
|
||||||
super::execute_sql(manager, sql).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.get_connection()
|
|
||||||
.execute_raw(sea_orm::Statement::from_string(
|
|
||||||
sea_orm::DbBackend::Postgres,
|
|
||||||
r#"
|
|
||||||
ALTER TABLE user_notification
|
|
||||||
DROP COLUMN IF EXISTS push_subscription_endpoint,
|
|
||||||
DROP COLUMN IF EXISTS push_subscription_keys_p256dh,
|
|
||||||
DROP COLUMN IF EXISTS push_subscription_keys_auth;
|
|
||||||
"#.to_string(),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
//! SeaORM migration: add model_id column to room_message
|
|
||||||
|
|
||||||
use sea_orm_migration::prelude::*;
|
|
||||||
|
|
||||||
pub struct Migration;
|
|
||||||
|
|
||||||
impl MigrationName for Migration {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"m20260420_000003_add_model_id_to_room_message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl MigrationTrait for Migration {
|
|
||||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
let sql = include_str!("sql/m20260420_000003_add_model_id_to_room_message.sql");
|
|
||||||
super::execute_sql(manager, sql).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
|
||||||
manager
|
|
||||||
.get_connection()
|
|
||||||
.execute_raw(
|
|
||||||
sea_orm::DbBackend::Postgres,
|
|
||||||
"ALTER TABLE room_message DROP COLUMN IF EXISTS model_id;",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS room_attachment (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
room UUID NOT NULL,
|
|
||||||
message UUID NOT NULL,
|
|
||||||
uploader UUID NOT NULL,
|
|
||||||
file_name VARCHAR(255) NOT NULL,
|
|
||||||
file_size BIGINT NOT NULL,
|
|
||||||
content_type VARCHAR(100) NOT NULL,
|
|
||||||
s3_key VARCHAR(500) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_room_attachment_room ON room_attachment (room);
|
|
||||||
CREATE INDEX idx_room_attachment_message ON room_attachment (message);
|
|
||||||
CREATE INDEX idx_room_attachment_uploader ON room_attachment (uploader);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- Add push subscription fields to user_notification
|
|
||||||
ALTER TABLE user_notification
|
|
||||||
ADD COLUMN push_subscription_endpoint TEXT,
|
|
||||||
ADD COLUMN push_subscription_keys_p256dh TEXT,
|
|
||||||
ADD COLUMN push_subscription_keys_auth TEXT;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE room_message ADD COLUMN IF NOT EXISTS model_id UUID;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_room_message_model_id ON room_message (model_id) WHERE model_id IS NOT NULL;
|
|
||||||
@ -114,7 +114,6 @@ impl std::fmt::Display for ToolCallStatus {
|
|||||||
|
|
||||||
pub use room::Entity as Room;
|
pub use room::Entity as Room;
|
||||||
pub use room_ai::Entity as RoomAi;
|
pub use room_ai::Entity as RoomAi;
|
||||||
pub use room_attachment::Entity as RoomAttachment;
|
|
||||||
pub use room_category::Entity as RoomCategory;
|
pub use room_category::Entity as RoomCategory;
|
||||||
pub use room_member::Entity as RoomMember;
|
pub use room_member::Entity as RoomMember;
|
||||||
pub use room_message::Entity as RoomMessage;
|
pub use room_message::Entity as RoomMessage;
|
||||||
@ -126,7 +125,6 @@ pub use room_pin::Entity as RoomPin;
|
|||||||
pub use room_thread::Entity as RoomThread;
|
pub use room_thread::Entity as RoomThread;
|
||||||
pub mod room;
|
pub mod room;
|
||||||
pub mod room_ai;
|
pub mod room_ai;
|
||||||
pub mod room_attachment;
|
|
||||||
pub mod room_category;
|
pub mod room_category;
|
||||||
pub mod room_member;
|
pub mod room_member;
|
||||||
pub mod room_message;
|
pub mod room_message;
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
use crate::{DateTimeUtc, MessageId, RoomId, UserId};
|
|
||||||
use sea_orm::entity::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
|
||||||
#[sea_orm(table_name = "room_attachment")]
|
|
||||||
pub struct Model {
|
|
||||||
#[sea_orm(primary_key)]
|
|
||||||
pub id: Uuid,
|
|
||||||
pub room: RoomId,
|
|
||||||
pub message: MessageId,
|
|
||||||
pub uploader: UserId,
|
|
||||||
pub file_name: String,
|
|
||||||
pub file_size: i64,
|
|
||||||
pub content_type: String,
|
|
||||||
pub s3_key: String,
|
|
||||||
pub created_at: DateTimeUtc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
||||||
pub enum Relation {}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
|
||||||
@ -13,8 +13,6 @@ pub struct Model {
|
|||||||
pub room: RoomId,
|
pub room: RoomId,
|
||||||
pub sender_type: MessageSenderType,
|
pub sender_type: MessageSenderType,
|
||||||
pub sender_id: Option<UserId>,
|
pub sender_id: Option<UserId>,
|
||||||
/// AI model ID — set when sender_type = "ai", used for display name lookups.
|
|
||||||
pub model_id: Option<Uuid>,
|
|
||||||
pub thread: Option<RoomThreadId>,
|
pub thread: Option<RoomThreadId>,
|
||||||
pub in_reply_to: Option<MessageId>,
|
pub in_reply_to: Option<MessageId>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|||||||
@ -18,10 +18,6 @@ pub enum NotificationType {
|
|||||||
RoomDeleted,
|
RoomDeleted,
|
||||||
#[sea_orm(string_value = "system_announcement")]
|
#[sea_orm(string_value = "system_announcement")]
|
||||||
SystemAnnouncement,
|
SystemAnnouncement,
|
||||||
#[sea_orm(string_value = "project_invitation")]
|
|
||||||
ProjectInvitation,
|
|
||||||
#[sea_orm(string_value = "workspace_invitation")]
|
|
||||||
WorkspaceInvitation,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for NotificationType {
|
impl std::fmt::Display for NotificationType {
|
||||||
@ -33,8 +29,6 @@ impl std::fmt::Display for NotificationType {
|
|||||||
NotificationType::RoomCreated => "room_created",
|
NotificationType::RoomCreated => "room_created",
|
||||||
NotificationType::RoomDeleted => "room_deleted",
|
NotificationType::RoomDeleted => "room_deleted",
|
||||||
NotificationType::SystemAnnouncement => "system_announcement",
|
NotificationType::SystemAnnouncement => "system_announcement",
|
||||||
NotificationType::ProjectInvitation => "project_invitation",
|
|
||||||
NotificationType::WorkspaceInvitation => "workspace_invitation",
|
|
||||||
};
|
};
|
||||||
write!(f, "{}", s)
|
write!(f, "{}", s)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,9 +47,6 @@ pub struct Model {
|
|||||||
pub marketing_enabled: bool,
|
pub marketing_enabled: bool,
|
||||||
pub security_enabled: bool,
|
pub security_enabled: bool,
|
||||||
pub product_enabled: bool,
|
pub product_enabled: bool,
|
||||||
pub push_subscription_endpoint: Option<String>,
|
|
||||||
pub push_subscription_keys_p256dh: Option<String>,
|
|
||||||
pub push_subscription_keys_auth: Option<String>,
|
|
||||||
pub created_at: DateTimeUtc,
|
pub created_at: DateTimeUtc,
|
||||||
pub updated_at: DateTimeUtc,
|
pub updated_at: DateTimeUtc,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,6 @@ pub struct RoomMessageEnvelope {
|
|||||||
pub room_id: Uuid,
|
pub room_id: Uuid,
|
||||||
pub sender_type: String,
|
pub sender_type: String,
|
||||||
pub sender_id: Option<Uuid>,
|
pub sender_id: Option<Uuid>,
|
||||||
/// AI model ID — set when sender_type = "ai", used for display name lookups.
|
|
||||||
pub model_id: Option<Uuid>,
|
|
||||||
pub thread_id: Option<Uuid>,
|
pub thread_id: Option<Uuid>,
|
||||||
pub in_reply_to: Option<Uuid>,
|
pub in_reply_to: Option<Uuid>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@ -89,8 +87,6 @@ pub struct RoomMessageStreamChunkEvent {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub done: bool,
|
pub done: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// Human-readable AI model name (e.g. "Claude 3.5 Sonnet") for display.
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@ -10,7 +10,7 @@ use uuid::Uuid;
|
|||||||
use db::database::AppDatabase;
|
use db::database::AppDatabase;
|
||||||
use models::rooms::{MessageContentType, MessageSenderType, room_message};
|
use models::rooms::{MessageContentType, MessageSenderType, room_message};
|
||||||
use queue::{AgentTaskEvent, ProjectRoomEvent, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent};
|
use queue::{AgentTaskEvent, ProjectRoomEvent, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent};
|
||||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::error::RoomError;
|
use crate::error::RoomError;
|
||||||
use crate::metrics::RoomMetrics;
|
use crate::metrics::RoomMetrics;
|
||||||
@ -720,7 +720,6 @@ pub fn make_persist_fn(
|
|||||||
room: Set(env.room_id),
|
room: Set(env.room_id),
|
||||||
sender_type: Set(sender_type),
|
sender_type: Set(sender_type),
|
||||||
sender_id: Set(env.sender_id),
|
sender_id: Set(env.sender_id),
|
||||||
model_id: Set(env.model_id),
|
|
||||||
thread: Set(env.thread_id),
|
thread: Set(env.thread_id),
|
||||||
content: Set(env.content.clone()),
|
content: Set(env.content.clone()),
|
||||||
content_type: Set(content_type),
|
content_type: Set(content_type),
|
||||||
@ -737,21 +736,6 @@ pub fn make_persist_fn(
|
|||||||
room_message::Entity::insert_many(models_to_insert)
|
room_message::Entity::insert_many(models_to_insert)
|
||||||
.exec(&db)
|
.exec(&db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update content_tsv for inserted messages
|
|
||||||
for env in chunk.iter() {
|
|
||||||
let update_sql = format!(
|
|
||||||
"UPDATE room_message SET content_tsv = to_tsvector('simple', content) WHERE id = '{}'",
|
|
||||||
env.id
|
|
||||||
);
|
|
||||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
|
||||||
sea_orm::DbBackend::Postgres,
|
|
||||||
&update_sql,
|
|
||||||
vec![],
|
|
||||||
);
|
|
||||||
let _ = db.execute_raw(stmt).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics.messages_persisted.increment(count);
|
metrics.messages_persisted.increment(count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,6 @@ impl From<room_message::Model> for super::RoomMessageResponse {
|
|||||||
revoked: value.revoked,
|
revoked: value.revoked,
|
||||||
revoked_by: value.revoked_by,
|
revoked_by: value.revoked_by,
|
||||||
in_reply_to: value.in_reply_to,
|
in_reply_to: value.in_reply_to,
|
||||||
highlighted_content: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -389,8 +388,8 @@ impl RoomService {
|
|||||||
let sender_type = msg.sender_type.to_string();
|
let sender_type = msg.sender_type.to_string();
|
||||||
let display_name = match sender_type.as_str() {
|
let display_name = match sender_type.as_str() {
|
||||||
"ai" => {
|
"ai" => {
|
||||||
if let Some(mid) = msg.model_id {
|
if let Some(sender_id) = msg.sender_id {
|
||||||
ai_model::Entity::find_by_id(mid)
|
ai_model::Entity::find_by_id(sender_id)
|
||||||
.one(&self.db)
|
.one(&self.db)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@ -430,7 +429,6 @@ impl RoomService {
|
|||||||
revoked: msg.revoked,
|
revoked: msg.revoked,
|
||||||
revoked_by: msg.revoked_by,
|
revoked_by: msg.revoked_by,
|
||||||
in_reply_to: msg.in_reply_to,
|
in_reply_to: msg.in_reply_to,
|
||||||
highlighted_content: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,5 +30,5 @@ pub use draft_and_history::{
|
|||||||
pub use error::RoomError;
|
pub use error::RoomError;
|
||||||
pub use metrics::RoomMetrics;
|
pub use metrics::RoomMetrics;
|
||||||
pub use reaction::{MessageReactionsResponse, MessageSearchResponse};
|
pub use reaction::{MessageReactionsResponse, MessageSearchResponse};
|
||||||
pub use service::{RoomService, PushNotificationFn};
|
pub use service::RoomService;
|
||||||
pub use types::{RoomEventType, *};
|
pub use types::{RoomEventType, *};
|
||||||
|
|||||||
@ -9,9 +9,6 @@ use sea_orm::*;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
impl RoomService {
|
impl RoomService {
|
||||||
/// Cache TTL for member list (in seconds).
|
|
||||||
const MEMBER_LIST_CACHE_TTL: u64 = 30;
|
|
||||||
|
|
||||||
pub async fn room_member_list(
|
pub async fn room_member_list(
|
||||||
&self,
|
&self,
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
@ -20,24 +17,6 @@ impl RoomService {
|
|||||||
let user_id = ctx.user_id;
|
let user_id = ctx.user_id;
|
||||||
self.require_room_member(room_id, user_id).await?;
|
self.require_room_member(room_id, user_id).await?;
|
||||||
|
|
||||||
// Try cache first
|
|
||||||
let cache_key = format!("room:members:{}", room_id);
|
|
||||||
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
if let Ok(Some(cached)) = redis::cmd("GET")
|
|
||||||
.arg(&cache_key)
|
|
||||||
.query_async::<Option<String>>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Ok(responses) = serde_json::from_str::<Vec<super::RoomMemberResponse>>(&cached) {
|
|
||||||
slog::debug!(self.log, "room_member_list: cache hit for key={}", cache_key);
|
|
||||||
return Ok(responses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slog::debug!(self.log, "room_member_list: cache miss for key={}", cache_key);
|
|
||||||
|
|
||||||
let members = room_member::Entity::find()
|
let members = room_member::Entity::find()
|
||||||
.filter(room_member::Column::Room.eq(room_id))
|
.filter(room_member::Column::Room.eq(room_id))
|
||||||
.all(&self.db)
|
.all(&self.db)
|
||||||
@ -81,23 +60,6 @@ impl RoomService {
|
|||||||
dnd_end_hour: m.dnd_end_hour,
|
dnd_end_hour: m.dnd_end_hour,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
if let Ok(json) = serde_json::to_string(&responses) {
|
|
||||||
let _: Option<String> = redis::cmd("SETEX")
|
|
||||||
.arg(&cache_key)
|
|
||||||
.arg(Self::MEMBER_LIST_CACHE_TTL)
|
|
||||||
.arg(&json)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| {
|
|
||||||
slog::warn!(self.log, "room_member_list: failed to cache key={}: {}", cache_key, e);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(responses)
|
Ok(responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,9 +121,6 @@ impl RoomService {
|
|||||||
|
|
||||||
drop(self.room_manager.subscribe(room_id, request.user_id).await);
|
drop(self.room_manager.subscribe(room_id, request.user_id).await);
|
||||||
|
|
||||||
// Invalidate member list cache
|
|
||||||
self.invalidate_member_list_cache(room_id).await;
|
|
||||||
|
|
||||||
self.publish_room_event(
|
self.publish_room_event(
|
||||||
room_model.project,
|
room_model.project,
|
||||||
super::RoomEventType::MemberJoined,
|
super::RoomEventType::MemberJoined,
|
||||||
@ -239,9 +198,6 @@ impl RoomService {
|
|||||||
active.role = Set(new_role);
|
active.role = Set(new_role);
|
||||||
let updated = active.update(&self.db).await?;
|
let updated = active.update(&self.db).await?;
|
||||||
|
|
||||||
// Invalidate member list cache
|
|
||||||
self.invalidate_member_list_cache(room_id).await;
|
|
||||||
|
|
||||||
let room = self.find_room_or_404(room_id).await?;
|
let room = self.find_room_or_404(room_id).await?;
|
||||||
let _ = self
|
let _ = self
|
||||||
.notification_create(super::NotificationCreateRequest {
|
.notification_create(super::NotificationCreateRequest {
|
||||||
@ -308,9 +264,6 @@ impl RoomService {
|
|||||||
.exec(&self.db)
|
.exec(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Invalidate member list cache
|
|
||||||
self.invalidate_member_list_cache(room_id).await;
|
|
||||||
|
|
||||||
self.room_manager.unsubscribe(room_id, user_id).await;
|
self.room_manager.unsubscribe(room_id, user_id).await;
|
||||||
|
|
||||||
let room = self.find_room_or_404(room_id).await?;
|
let room = self.find_room_or_404(room_id).await?;
|
||||||
@ -414,20 +367,4 @@ impl RoomService {
|
|||||||
};
|
};
|
||||||
Ok(updated_response)
|
Ok(updated_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate member list cache for a room.
|
|
||||||
async fn invalidate_member_list_cache(&self, room_id: Uuid) {
|
|
||||||
let cache_key = format!("room:members:{}", room_id);
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
if let Err(e) = redis::cmd("DEL")
|
|
||||||
.arg(&cache_key)
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
slog::warn!(self.log, "invalidate_member_list_cache: DEL failed for {}: {}", cache_key, e);
|
|
||||||
} else {
|
|
||||||
slog::debug!(self.log, "invalidate_member_list_cache: deleted {}", cache_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ impl RoomService {
|
|||||||
let ai_model_ids: Vec<Uuid> = models
|
let ai_model_ids: Vec<Uuid> = models
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.sender_type.to_string() == "ai")
|
.filter(|m| m.sender_type.to_string() == "ai")
|
||||||
.filter_map(|m| m.model_id)
|
.filter_map(|m| m.sender_id)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let users: std::collections::HashMap<Uuid, String> = if !user_ids.is_empty() {
|
let users: std::collections::HashMap<Uuid, String> = if !user_ids.is_empty() {
|
||||||
@ -78,7 +78,7 @@ impl RoomService {
|
|||||||
.map(|msg| {
|
.map(|msg| {
|
||||||
let sender_type = msg.sender_type.to_string();
|
let sender_type = msg.sender_type.to_string();
|
||||||
let display_name = match sender_type.as_str() {
|
let display_name = match sender_type.as_str() {
|
||||||
"ai" => msg.model_id.and_then(|id| ai_names.get(&id).cloned()),
|
"ai" => msg.sender_id.and_then(|id| ai_names.get(&id).cloned()),
|
||||||
_ => msg.sender_id.and_then(|id| users.get(&id).cloned()),
|
_ => msg.sender_id.and_then(|id| users.get(&id).cloned()),
|
||||||
};
|
};
|
||||||
super::RoomMessageResponse {
|
super::RoomMessageResponse {
|
||||||
@ -96,7 +96,6 @@ impl RoomService {
|
|||||||
send_at: msg.send_at,
|
send_at: msg.send_at,
|
||||||
revoked: msg.revoked,
|
revoked: msg.revoked,
|
||||||
revoked_by: msg.revoked_by,
|
revoked_by: msg.revoked_by,
|
||||||
highlighted_content: None,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -147,7 +146,6 @@ impl RoomService {
|
|||||||
room_id,
|
room_id,
|
||||||
sender_type: "member".to_string(),
|
sender_type: "member".to_string(),
|
||||||
sender_id: Some(user_id),
|
sender_id: Some(user_id),
|
||||||
model_id: None,
|
|
||||||
thread_id,
|
thread_id,
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
@ -277,7 +275,6 @@ impl RoomService {
|
|||||||
send_at: now,
|
send_at: now,
|
||||||
revoked: None,
|
revoked: None,
|
||||||
revoked_by: None,
|
revoked_by: None,
|
||||||
highlighted_content: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,12 +30,6 @@ impl RoomService {
|
|||||||
super::NotificationType::SystemAnnouncement => {
|
super::NotificationType::SystemAnnouncement => {
|
||||||
room_notifications::NotificationType::SystemAnnouncement
|
room_notifications::NotificationType::SystemAnnouncement
|
||||||
}
|
}
|
||||||
super::NotificationType::ProjectInvitation => {
|
|
||||||
room_notifications::NotificationType::ProjectInvitation
|
|
||||||
}
|
|
||||||
super::NotificationType::WorkspaceInvitation => {
|
|
||||||
room_notifications::NotificationType::WorkspaceInvitation
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let model = room_notifications::ActiveModel {
|
let model = room_notifications::ActiveModel {
|
||||||
@ -267,20 +261,10 @@ impl RoomService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
notification: super::NotificationResponse,
|
notification: super::NotificationResponse,
|
||||||
) {
|
) {
|
||||||
let event = super::NotificationEvent::new(notification.clone());
|
let event = super::NotificationEvent::new(notification);
|
||||||
self.room_manager
|
self.room_manager
|
||||||
.push_user_notification(user_id, Arc::new(event))
|
.push_user_notification(user_id, Arc::new(event))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Also trigger Web Push for offline users
|
|
||||||
if let Some(push_fn) = &self.push_fn {
|
|
||||||
push_fn(
|
|
||||||
user_id,
|
|
||||||
notification.title.clone(),
|
|
||||||
notification.content.clone(),
|
|
||||||
None, // URL — could be derived from room/project
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unread_cache_key(user_id: Uuid) -> String {
|
fn unread_cache_key(user_id: Uuid) -> String {
|
||||||
|
|||||||
@ -325,7 +325,6 @@ impl RoomService {
|
|||||||
send_at: msg.send_at,
|
send_at: msg.send_at,
|
||||||
revoked: msg.revoked,
|
revoked: msg.revoked,
|
||||||
revoked_by: msg.revoked_by,
|
revoked_by: msg.revoked_by,
|
||||||
highlighted_content: None,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@ -11,9 +11,6 @@ use sea_orm::*;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
impl RoomService {
|
impl RoomService {
|
||||||
/// Cache TTL for room list (in seconds).
|
|
||||||
const ROOM_LIST_CACHE_TTL: u64 = 60;
|
|
||||||
|
|
||||||
pub async fn room_list(
|
pub async fn room_list(
|
||||||
&self,
|
&self,
|
||||||
project_name: String,
|
project_name: String,
|
||||||
@ -24,29 +21,6 @@ impl RoomService {
|
|||||||
let project = self.utils_find_project_by_name(project_name).await?;
|
let project = self.utils_find_project_by_name(project_name).await?;
|
||||||
self.check_project_access(project.id, user_id).await?;
|
self.check_project_access(project.id, user_id).await?;
|
||||||
|
|
||||||
// Try cache first
|
|
||||||
let cache_key = format!(
|
|
||||||
"room:list:{}:{}:public={}",
|
|
||||||
project.id,
|
|
||||||
user_id,
|
|
||||||
only_public.unwrap_or(false)
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
if let Ok(Some(cached)) = redis::cmd("GET")
|
|
||||||
.arg(&cache_key)
|
|
||||||
.query_async::<Option<String>>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Ok(responses) = serde_json::from_str::<Vec<super::RoomResponse>>(&cached) {
|
|
||||||
slog::debug!(self.log, "room_list: cache hit for key={}", cache_key);
|
|
||||||
return Ok(responses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slog::debug!(self.log, "room_list: cache miss for key={}", cache_key);
|
|
||||||
|
|
||||||
let mut query = room::Entity::find().filter(room::Column::Project.eq(project.id));
|
let mut query = room::Entity::find().filter(room::Column::Project.eq(project.id));
|
||||||
if only_public.unwrap_or(false) {
|
if only_public.unwrap_or(false) {
|
||||||
query = query.filter(room::Column::Public.eq(true));
|
query = query.filter(room::Column::Public.eq(true));
|
||||||
@ -92,22 +66,6 @@ impl RoomService {
|
|||||||
responses.push(response);
|
responses.push(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
if let Ok(json) = serde_json::to_string(&responses) {
|
|
||||||
let _: Option<String> = redis::cmd("SETEX")
|
|
||||||
.arg(&cache_key)
|
|
||||||
.arg(Self::ROOM_LIST_CACHE_TTL)
|
|
||||||
.arg(&json)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| {
|
|
||||||
slog::warn!(self.log, "room_list: failed to cache key={}: {}", cache_key, e);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(responses)
|
Ok(responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,9 +156,6 @@ impl RoomService {
|
|||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
// Invalidate room list cache for this project
|
|
||||||
self.invalidate_room_list_cache(project.id).await;
|
|
||||||
|
|
||||||
self.spawn_room_workers(room_model.id);
|
self.spawn_room_workers(room_model.id);
|
||||||
|
|
||||||
let event = ProjectRoomEvent {
|
let event = ProjectRoomEvent {
|
||||||
@ -277,9 +232,6 @@ impl RoomService {
|
|||||||
}
|
}
|
||||||
let updated = active.update(&self.db).await?;
|
let updated = active.update(&self.db).await?;
|
||||||
|
|
||||||
// Invalidate room list cache
|
|
||||||
self.invalidate_room_list_cache(updated.project).await;
|
|
||||||
|
|
||||||
if renamed {
|
if renamed {
|
||||||
let event = ProjectRoomEvent {
|
let event = ProjectRoomEvent {
|
||||||
event_type: super::RoomEventType::RoomRenamed.as_str().into(),
|
event_type: super::RoomEventType::RoomRenamed.as_str().into(),
|
||||||
@ -351,9 +303,6 @@ impl RoomService {
|
|||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
// Invalidate room list cache
|
|
||||||
self.invalidate_room_list_cache(project_id).await;
|
|
||||||
|
|
||||||
self.room_manager.shutdown_room(room_id).await;
|
self.room_manager.shutdown_room(room_id).await;
|
||||||
|
|
||||||
// Clean up Redis seq key so re-creating the room starts fresh
|
// Clean up Redis seq key so re-creating the room starts fresh
|
||||||
@ -393,49 +342,4 @@ impl RoomService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate all room list cache entries for a project.
|
|
||||||
async fn invalidate_room_list_cache(&self, project_id: Uuid) {
|
|
||||||
let pattern = format!("room:list:{}:*", project_id);
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
|
||||||
// Use SCAN to find matching keys, then DELETE them
|
|
||||||
let mut cursor: u64 = 0;
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = match redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg(&pattern)
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(e) => {
|
|
||||||
slog::warn!(self.log, "invalidate_room_list_cache: SCAN failed: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
cursor = new_cursor;
|
|
||||||
|
|
||||||
if !keys.is_empty() {
|
|
||||||
// Delete keys in batches
|
|
||||||
let keys_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
|
|
||||||
if let Err(e) = redis::cmd("DEL")
|
|
||||||
.arg(&keys_refs)
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
slog::warn!(self.log, "invalidate_room_list_cache: DEL failed: {}", e);
|
|
||||||
} else {
|
|
||||||
slog::debug!(self.log, "invalidate_room_list_cache: deleted {} keys", keys.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
use crate::error::RoomError;
|
use crate::error::RoomError;
|
||||||
use crate::service::RoomService;
|
use crate::service::RoomService;
|
||||||
use crate::types::RoomMessageSearchRequest;
|
|
||||||
use crate::ws_context::WsUserContext;
|
use crate::ws_context::WsUserContext;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use models::rooms::{room_message, room_message_reaction};
|
use models::rooms::{room_message, room_message_reaction};
|
||||||
@ -12,177 +11,139 @@ impl RoomService {
|
|||||||
pub async fn room_message_search(
|
pub async fn room_message_search(
|
||||||
&self,
|
&self,
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
request: RoomMessageSearchRequest,
|
query: &str,
|
||||||
|
limit: Option<u64>,
|
||||||
|
offset: Option<u64>,
|
||||||
ctx: &WsUserContext,
|
ctx: &WsUserContext,
|
||||||
) -> Result<super::MessageSearchResponse, RoomError> {
|
) -> Result<super::MessageSearchResponse, RoomError> {
|
||||||
let user_id = ctx.user_id;
|
let user_id = ctx.user_id;
|
||||||
self.require_room_member(room_id, user_id).await?;
|
self.require_room_member(room_id, user_id).await?;
|
||||||
|
|
||||||
if request.q.trim().is_empty() {
|
if query.trim().is_empty() {
|
||||||
return Ok(super::MessageSearchResponse {
|
return Ok(super::MessageSearchResponse {
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = std::cmp::min(request.limit.unwrap_or(20), 100);
|
let limit = std::cmp::min(limit.unwrap_or(20), 100);
|
||||||
let offset = request.offset.unwrap_or(0);
|
let offset = offset.unwrap_or(0);
|
||||||
|
|
||||||
// Build dynamic WHERE conditions
|
// PostgreSQL full-text search via raw SQL with parameterized query.
|
||||||
let mut conditions = vec![
|
// plainto_tsquery('simple', $1) is injection-safe — it treats input as text.
|
||||||
"room = $1".to_string(),
|
let sql = r#"
|
||||||
"content_tsv @@ plainto_tsquery('simple', $2)".to_string(),
|
|
||||||
"revoked IS NULL".to_string(),
|
|
||||||
];
|
|
||||||
let mut param_index = 3;
|
|
||||||
let mut params: Vec<sea_orm::Value> = vec![room_id.into(), request.q.trim().into()];
|
|
||||||
|
|
||||||
// Add time range filter
|
|
||||||
if let Some(start_time) = request.start_time {
|
|
||||||
conditions.push(format!("send_at >= ${}", param_index));
|
|
||||||
params.push(start_time.into());
|
|
||||||
param_index += 1;
|
|
||||||
}
|
|
||||||
if let Some(end_time) = request.end_time {
|
|
||||||
conditions.push(format!("send_at <= ${}", param_index));
|
|
||||||
params.push(end_time.into());
|
|
||||||
param_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sender filter
|
|
||||||
if let Some(sender_id) = request.sender_id {
|
|
||||||
conditions.push(format!("sender_id = ${}", param_index));
|
|
||||||
params.push(sender_id.into());
|
|
||||||
param_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add content type filter
|
|
||||||
if let Some(ref content_type) = request.content_type {
|
|
||||||
conditions.push(format!("content_type = ${}", param_index));
|
|
||||||
params.push(content_type.clone().into());
|
|
||||||
param_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = conditions.join(" AND ");
|
|
||||||
|
|
||||||
// PostgreSQL full-text search with highlighting via raw SQL.
|
|
||||||
// Uses ts_headline for result highlighting with <mark> tags.
|
|
||||||
let sql = format!(
|
|
||||||
r#"
|
|
||||||
SELECT id, seq, room, sender_type, sender_id, thread, in_reply_to,
|
SELECT id, seq, room, sender_type, sender_id, thread, in_reply_to,
|
||||||
content, content_type, edited_at, send_at, revoked, revoked_by,
|
content, content_type, edited_at, send_at, revoked, revoked_by
|
||||||
ts_headline('simple', content, plainto_tsquery('simple', $2),
|
|
||||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=15') AS highlighted_content
|
|
||||||
FROM room_message
|
FROM room_message
|
||||||
WHERE {}
|
WHERE room = $1
|
||||||
|
AND content_tsv @@ plainto_tsquery('simple', $2)
|
||||||
|
AND revoked IS NULL
|
||||||
ORDER BY send_at DESC
|
ORDER BY send_at DESC
|
||||||
LIMIT ${} OFFSET ${}"#,
|
LIMIT $3 OFFSET $4"#;
|
||||||
where_clause,
|
|
||||||
param_index,
|
let stmt = Statement::from_sql_and_values(
|
||||||
param_index + 1
|
DbBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
vec![
|
||||||
|
room_id.into(),
|
||||||
|
query.trim().into(),
|
||||||
|
limit.into(),
|
||||||
|
offset.into(),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
params.push(limit.into());
|
let rows: Vec<room_message::Model> = self
|
||||||
params.push(offset.into());
|
.db
|
||||||
|
.query_all_raw(stmt)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let sender_type = row
|
||||||
|
.try_get::<String>("", "sender_type")
|
||||||
|
.map(|s| match s.as_str() {
|
||||||
|
"admin" => models::rooms::MessageSenderType::Admin,
|
||||||
|
"owner" => models::rooms::MessageSenderType::Owner,
|
||||||
|
"ai" => models::rooms::MessageSenderType::Ai,
|
||||||
|
"system" => models::rooms::MessageSenderType::System,
|
||||||
|
"tool" => models::rooms::MessageSenderType::Tool,
|
||||||
|
"guest" => models::rooms::MessageSenderType::Guest,
|
||||||
|
_ => models::rooms::MessageSenderType::Member,
|
||||||
|
})
|
||||||
|
.unwrap_or(models::rooms::MessageSenderType::Member);
|
||||||
|
|
||||||
let stmt = Statement::from_sql_and_values(DbBackend::Postgres, &sql, params);
|
let content_type = row
|
||||||
|
.try_get::<String>("", "content_type")
|
||||||
|
.map(|s| match s.as_str() {
|
||||||
|
"image" => models::rooms::MessageContentType::Image,
|
||||||
|
"audio" => models::rooms::MessageContentType::Audio,
|
||||||
|
"video" => models::rooms::MessageContentType::Video,
|
||||||
|
"file" => models::rooms::MessageContentType::File,
|
||||||
|
_ => models::rooms::MessageContentType::Text,
|
||||||
|
})
|
||||||
|
.unwrap_or(models::rooms::MessageContentType::Text);
|
||||||
|
|
||||||
let rows = self.db.query_all_raw(stmt).await?;
|
room_message::Model {
|
||||||
|
id: row.try_get::<MessageId>("", "id").unwrap_or_default(),
|
||||||
|
seq: row.try_get::<Seq>("", "seq").unwrap_or_default(),
|
||||||
|
room: row.try_get::<RoomId>("", "room").unwrap_or_default(),
|
||||||
|
sender_type,
|
||||||
|
sender_id: row
|
||||||
|
.try_get::<Option<UserId>>("", "sender_id")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
thread: row
|
||||||
|
.try_get::<Option<RoomThreadId>>("", "thread")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
in_reply_to: row
|
||||||
|
.try_get::<Option<MessageId>>("", "in_reply_to")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
content: row.try_get::<String>("", "content").unwrap_or_default(),
|
||||||
|
content_type,
|
||||||
|
edited_at: row
|
||||||
|
.try_get::<Option<DateTimeUtc>>("", "edited_at")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
send_at: row
|
||||||
|
.try_get::<DateTimeUtc>("", "send_at")
|
||||||
|
.unwrap_or_default(),
|
||||||
|
revoked: row
|
||||||
|
.try_get::<Option<DateTimeUtc>>("", "revoked")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
revoked_by: row
|
||||||
|
.try_get::<Option<UserId>>("", "revoked_by")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
content_tsv: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Parse results and build response with highlighted content
|
// Efficient COUNT query.
|
||||||
let mut results: Vec<super::RoomMessageResponse> = Vec::new();
|
let count_sql = r#"
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM room_message
|
||||||
|
WHERE room = $1
|
||||||
|
AND content_tsv @@ plainto_tsquery('simple', $2)
|
||||||
|
AND revoked IS NULL"#;
|
||||||
|
|
||||||
for row in rows {
|
let count_stmt = Statement::from_sql_and_values(
|
||||||
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
|
DbBackend::Postgres,
|
||||||
let sender_type = match sender_type_str.as_str() {
|
count_sql,
|
||||||
"admin" => models::rooms::MessageSenderType::Admin,
|
vec![room_id.into(), query.trim().into()],
|
||||||
"owner" => models::rooms::MessageSenderType::Owner,
|
|
||||||
"ai" => models::rooms::MessageSenderType::Ai,
|
|
||||||
"system" => models::rooms::MessageSenderType::System,
|
|
||||||
"tool" => models::rooms::MessageSenderType::Tool,
|
|
||||||
"guest" => models::rooms::MessageSenderType::Guest,
|
|
||||||
_ => models::rooms::MessageSenderType::Member,
|
|
||||||
};
|
|
||||||
|
|
||||||
let content_type_str = row.try_get::<String>("", "content_type").unwrap_or_default();
|
|
||||||
let content_type = match content_type_str.as_str() {
|
|
||||||
"image" => models::rooms::MessageContentType::Image,
|
|
||||||
"audio" => models::rooms::MessageContentType::Audio,
|
|
||||||
"video" => models::rooms::MessageContentType::Video,
|
|
||||||
"file" => models::rooms::MessageContentType::File,
|
|
||||||
_ => models::rooms::MessageContentType::Text,
|
|
||||||
};
|
|
||||||
|
|
||||||
let msg = room_message::Model {
|
|
||||||
id: row.try_get::<MessageId>("", "id").unwrap_or_default(),
|
|
||||||
seq: row.try_get::<Seq>("", "seq").unwrap_or_default(),
|
|
||||||
room: row.try_get::<RoomId>("", "room").unwrap_or_default(),
|
|
||||||
sender_type,
|
|
||||||
sender_id: row.try_get::<Option<UserId>>("", "sender_id").ok().flatten(),
|
|
||||||
thread: row.try_get::<Option<RoomThreadId>>("", "thread").ok().flatten(),
|
|
||||||
in_reply_to: row.try_get::<Option<MessageId>>("", "in_reply_to").ok().flatten(),
|
|
||||||
content: row.try_get::<String>("", "content").unwrap_or_default(),
|
|
||||||
content_type,
|
|
||||||
edited_at: row.try_get::<Option<DateTimeUtc>>("", "edited_at").ok().flatten(),
|
|
||||||
send_at: row.try_get::<DateTimeUtc>("", "send_at").unwrap_or_default(),
|
|
||||||
revoked: row.try_get::<Option<DateTimeUtc>>("", "revoked").ok().flatten(),
|
|
||||||
revoked_by: row.try_get::<Option<UserId>>("", "revoked_by").ok().flatten(),
|
|
||||||
content_tsv: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let highlighted_content = row
|
|
||||||
.try_get::<String>("", "highlighted_content")
|
|
||||||
.unwrap_or_else(|_| msg.content.clone());
|
|
||||||
|
|
||||||
// Resolve display name for this message
|
|
||||||
let message_with_name = self.resolve_display_name(msg.clone(), room_id).await;
|
|
||||||
|
|
||||||
let mut msg_with_name = message_with_name;
|
|
||||||
msg_with_name.highlighted_content = Some(highlighted_content);
|
|
||||||
results.push(msg_with_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// COUNT query for total (without pagination)
|
|
||||||
let mut count_conditions = vec![
|
|
||||||
"room = $1".to_string(),
|
|
||||||
"content_tsv @@ plainto_tsquery('simple', $2)".to_string(),
|
|
||||||
"revoked IS NULL".to_string(),
|
|
||||||
];
|
|
||||||
let mut count_params: Vec<sea_orm::Value> = vec![room_id.into(), request.q.trim().into()];
|
|
||||||
let mut count_param_idx = 3;
|
|
||||||
|
|
||||||
if let Some(start_time) = request.start_time {
|
|
||||||
count_conditions.push(format!("send_at >= ${}", count_param_idx));
|
|
||||||
count_params.push(start_time.into());
|
|
||||||
count_param_idx += 1;
|
|
||||||
}
|
|
||||||
if let Some(end_time) = request.end_time {
|
|
||||||
count_conditions.push(format!("send_at <= ${}", count_param_idx));
|
|
||||||
count_params.push(end_time.into());
|
|
||||||
count_param_idx += 1;
|
|
||||||
}
|
|
||||||
if let Some(sender_id) = request.sender_id {
|
|
||||||
count_conditions.push(format!("sender_id = ${}", count_param_idx));
|
|
||||||
count_params.push(sender_id.into());
|
|
||||||
count_param_idx += 1;
|
|
||||||
}
|
|
||||||
if let Some(ref content_type) = request.content_type {
|
|
||||||
count_conditions.push(format!("content_type = ${}", count_param_idx));
|
|
||||||
count_params.push(content_type.clone().into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let count_sql = format!(
|
|
||||||
"SELECT COUNT(*) AS count FROM room_message WHERE {}",
|
|
||||||
count_conditions.join(" AND ")
|
|
||||||
);
|
);
|
||||||
let count_stmt = Statement::from_sql_and_values(DbBackend::Postgres, &count_sql, count_params);
|
|
||||||
let count_row = self.db.query_one_raw(count_stmt).await?;
|
let count_row = self.db.query_one_raw(count_stmt).await?;
|
||||||
let total: i64 = count_row
|
let total: i64 = count_row
|
||||||
.and_then(|r| r.try_get::<i64>("", "count").ok())
|
.and_then(|r| r.try_get::<i64>("", "count").ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let response_messages = self.build_messages_with_display_names(rows).await;
|
||||||
|
|
||||||
Ok(super::MessageSearchResponse {
|
Ok(super::MessageSearchResponse {
|
||||||
messages: results,
|
messages: response_messages,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,11 +24,6 @@ use models::agent_task::AgentType;
|
|||||||
|
|
||||||
const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
|
const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
|
||||||
|
|
||||||
/// Callback type for sending push notifications.
|
|
||||||
/// The caller (AppService) provides this to RoomService so it can trigger
|
|
||||||
/// browser push notifications without depending on the service crate directly.
|
|
||||||
pub type PushNotificationFn = Arc<dyn Fn(Uuid, String, Option<String>, Option<String>) + Send + Sync>;
|
|
||||||
|
|
||||||
/// Legacy: <user>uuid</user> or <user>username</user>
|
/// Legacy: <user>uuid</user> or <user>username</user>
|
||||||
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
|
||||||
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
|
LazyLock::new(|| regex_lite::Regex::new(r"<user>\s*([^<]+?)\s*</user>").unwrap());
|
||||||
@ -59,7 +54,6 @@ pub struct RoomService {
|
|||||||
pub chat_service: Option<Arc<ChatService>>,
|
pub chat_service: Option<Arc<ChatService>>,
|
||||||
pub task_service: Option<Arc<TaskService>>,
|
pub task_service: Option<Arc<TaskService>>,
|
||||||
pub log: slog::Logger,
|
pub log: slog::Logger,
|
||||||
pub push_fn: Option<PushNotificationFn>,
|
|
||||||
worker_semaphore: Arc<tokio::sync::Semaphore>,
|
worker_semaphore: Arc<tokio::sync::Semaphore>,
|
||||||
dedup_cache: DedupCache,
|
dedup_cache: DedupCache,
|
||||||
}
|
}
|
||||||
@ -75,7 +69,6 @@ impl RoomService {
|
|||||||
task_service: Option<Arc<TaskService>>,
|
task_service: Option<Arc<TaskService>>,
|
||||||
log: slog::Logger,
|
log: slog::Logger,
|
||||||
max_concurrent_workers: Option<usize>,
|
max_concurrent_workers: Option<usize>,
|
||||||
push_fn: Option<PushNotificationFn>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let dedup_cache: DedupCache =
|
let dedup_cache: DedupCache =
|
||||||
Arc::new(DashMap::with_capacity_and_hasher(10000, Default::default()));
|
Arc::new(DashMap::with_capacity_and_hasher(10000, Default::default()));
|
||||||
@ -92,7 +85,6 @@ impl RoomService {
|
|||||||
max_concurrent_workers.unwrap_or(DEFAULT_MAX_CONCURRENT_WORKERS),
|
max_concurrent_workers.unwrap_or(DEFAULT_MAX_CONCURRENT_WORKERS),
|
||||||
)),
|
)),
|
||||||
dedup_cache,
|
dedup_cache,
|
||||||
push_fn,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -531,12 +523,6 @@ impl RoomService {
|
|||||||
super::NotificationType::SystemAnnouncement => {
|
super::NotificationType::SystemAnnouncement => {
|
||||||
room_notifications::NotificationType::SystemAnnouncement
|
room_notifications::NotificationType::SystemAnnouncement
|
||||||
}
|
}
|
||||||
super::NotificationType::ProjectInvitation => {
|
|
||||||
room_notifications::NotificationType::ProjectInvitation
|
|
||||||
}
|
|
||||||
super::NotificationType::WorkspaceInvitation => {
|
|
||||||
room_notifications::NotificationType::WorkspaceInvitation
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let _model = room_notifications::ActiveModel {
|
let _model = room_notifications::ActiveModel {
|
||||||
@ -989,9 +975,7 @@ impl RoomService {
|
|||||||
let room_manager = room_manager.clone();
|
let room_manager = room_manager.clone();
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let model_id = model_id;
|
let model_id = model_id;
|
||||||
// Clone before closure so closure captures clone, not the original.
|
let ai_display_name = ai_display_name;
|
||||||
let ai_display_name_for_chunk = ai_display_name.clone();
|
|
||||||
let ai_display_name_for_final = ai_display_name.clone();
|
|
||||||
|
|
||||||
let streaming_msg_id = streaming_msg_id;
|
let streaming_msg_id = streaming_msg_id;
|
||||||
let room_id_for_chunk = room_id_inner;
|
let room_id_for_chunk = room_id_inner;
|
||||||
@ -1004,8 +988,6 @@ impl RoomService {
|
|||||||
let streaming_msg_id = streaming_msg_id;
|
let streaming_msg_id = streaming_msg_id;
|
||||||
let room_id = room_id_for_chunk;
|
let room_id = room_id_for_chunk;
|
||||||
let chunk_count = chunk_count.clone();
|
let chunk_count = chunk_count.clone();
|
||||||
// Clone display_name INSIDE the async block so the outer closure stays `Fn`.
|
|
||||||
let ai_display_name_for_chunk = ai_display_name_for_chunk.clone();
|
|
||||||
async move {
|
async move {
|
||||||
let event = RoomMessageStreamChunkEvent {
|
let event = RoomMessageStreamChunkEvent {
|
||||||
message_id: streaming_msg_id,
|
message_id: streaming_msg_id,
|
||||||
@ -1013,7 +995,6 @@ impl RoomService {
|
|||||||
content: chunk.content,
|
content: chunk.content,
|
||||||
done: chunk.done,
|
done: chunk.done,
|
||||||
error: None,
|
error: None,
|
||||||
display_name: Some(ai_display_name_for_chunk),
|
|
||||||
};
|
};
|
||||||
room_manager.broadcast_stream_chunk(event).await;
|
room_manager.broadcast_stream_chunk(event).await;
|
||||||
|
|
||||||
@ -1045,7 +1026,6 @@ impl RoomService {
|
|||||||
room_id: room_id_inner,
|
room_id: room_id_inner,
|
||||||
sender_type: sender_type.clone(),
|
sender_type: sender_type.clone(),
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
model_id: Some(model_id),
|
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
content: full_content.clone(),
|
content: full_content.clone(),
|
||||||
content_type: "text".to_string(),
|
content_type: "text".to_string(),
|
||||||
@ -1082,7 +1062,7 @@ impl RoomService {
|
|||||||
content_type: "text".to_string(),
|
content_type: "text".to_string(),
|
||||||
send_at: now,
|
send_at: now,
|
||||||
seq,
|
seq,
|
||||||
display_name: Some(ai_display_name_for_final.clone()),
|
display_name: Some(ai_display_name.clone()),
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
reactions: None,
|
reactions: None,
|
||||||
message_id: None,
|
message_id: None,
|
||||||
@ -1112,7 +1092,6 @@ impl RoomService {
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
done: true,
|
done: true,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
display_name: Some(ai_display_name.clone()),
|
|
||||||
};
|
};
|
||||||
room_manager.broadcast_stream_chunk(event).await;
|
room_manager.broadcast_stream_chunk(event).await;
|
||||||
}
|
}
|
||||||
@ -1155,7 +1134,6 @@ impl RoomService {
|
|||||||
project_id_for_ai,
|
project_id_for_ai,
|
||||||
Uuid::now_v7(),
|
Uuid::now_v7(),
|
||||||
response,
|
response,
|
||||||
model_id_inner,
|
|
||||||
Some(model_display_name),
|
Some(model_display_name),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -1194,7 +1172,6 @@ impl RoomService {
|
|||||||
project_id: Uuid,
|
project_id: Uuid,
|
||||||
_reply_to: Uuid,
|
_reply_to: Uuid,
|
||||||
content: String,
|
content: String,
|
||||||
model_id: Uuid,
|
|
||||||
model_display_name: Option<String>,
|
model_display_name: Option<String>,
|
||||||
) -> Result<Uuid, RoomError> {
|
) -> Result<Uuid, RoomError> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
@ -1207,7 +1184,6 @@ impl RoomService {
|
|||||||
room_id,
|
room_id,
|
||||||
sender_type: "ai".to_string(),
|
sender_type: "ai".to_string(),
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
model_id: Some(model_id),
|
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
content_type: "text".to_string(),
|
content_type: "text".to_string(),
|
||||||
|
|||||||
@ -126,7 +126,7 @@ pub struct RoomUpdateRequest {
|
|||||||
pub category: Option<Uuid>,
|
pub category: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
pub struct RoomResponse {
|
pub struct RoomResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub project: Uuid,
|
pub project: Uuid,
|
||||||
@ -157,7 +157,7 @@ pub struct RoomMemberReadSeqRequest {
|
|||||||
pub last_read_seq: i64,
|
pub last_read_seq: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
pub struct RoomMemberResponse {
|
pub struct RoomMemberResponse {
|
||||||
pub room: Uuid,
|
pub room: Uuid,
|
||||||
pub user: Uuid,
|
pub user: Uuid,
|
||||||
@ -192,17 +192,6 @@ pub struct RoomMessageUpdateRequest {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct RoomMessageSearchRequest {
|
|
||||||
pub q: String,
|
|
||||||
pub start_time: Option<DateTime<Utc>>,
|
|
||||||
pub end_time: Option<DateTime<Utc>>,
|
|
||||||
pub sender_id: Option<Uuid>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub limit: Option<u64>,
|
|
||||||
pub offset: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
pub struct RoomMessageResponse {
|
pub struct RoomMessageResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@ -219,16 +208,6 @@ pub struct RoomMessageResponse {
|
|||||||
pub send_at: DateTime<Utc>,
|
pub send_at: DateTime<Utc>,
|
||||||
pub revoked: Option<DateTime<Utc>>,
|
pub revoked: Option<DateTime<Utc>>,
|
||||||
pub revoked_by: Option<Uuid>,
|
pub revoked_by: Option<Uuid>,
|
||||||
/// Highlighted content with <mark> tags around matched terms (for search results)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub highlighted_content: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search result wrapper (keeps API compatibility)
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
|
||||||
pub struct RoomMessageSearchResult {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub message: RoomMessageResponse,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
@ -306,8 +285,6 @@ pub enum NotificationType {
|
|||||||
RoomCreated,
|
RoomCreated,
|
||||||
RoomDeleted,
|
RoomDeleted,
|
||||||
SystemAnnouncement,
|
SystemAnnouncement,
|
||||||
ProjectInvitation,
|
|
||||||
WorkspaceInvitation,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@ -43,10 +43,6 @@ base64 = { workspace = true }
|
|||||||
rsa = { workspace = true }
|
rsa = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
base64ct = { workspace = true }
|
|
||||||
p256 = { workspace = true }
|
|
||||||
jwt-simple = { version = "0.12.6", features = ["pure-rust"], default-features = false }
|
|
||||||
http = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
sha1 = { workspace = true }
|
sha1 = { workspace = true }
|
||||||
@ -69,7 +65,6 @@ zip = { workspace = true }
|
|||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
flate2 = { workspace = true }
|
flate2 = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
web-push-native = { version = "0.4.0", features = ["vapid"] }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@ -20,11 +20,6 @@ use slog::{Drain, OwnedKVList, Record};
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use ws_token::WsTokenService;
|
use ws_token::WsTokenService;
|
||||||
|
|
||||||
pub mod storage;
|
|
||||||
pub use storage::AppStorage;
|
|
||||||
pub mod push;
|
|
||||||
pub use push::{WebPushService, PushPayload};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppService {
|
pub struct AppService {
|
||||||
pub db: AppDatabase,
|
pub db: AppDatabase,
|
||||||
@ -36,48 +31,6 @@ pub struct AppService {
|
|||||||
pub room: RoomService,
|
pub room: RoomService,
|
||||||
pub ws_token: Arc<WsTokenService>,
|
pub ws_token: Arc<WsTokenService>,
|
||||||
pub queue_producer: MessageProducer,
|
pub queue_producer: MessageProducer,
|
||||||
pub storage: Option<AppStorage>,
|
|
||||||
pub push: Option<WebPushService>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppService {
|
|
||||||
/// Send a Web Push notification to a specific user.
|
|
||||||
/// Reads the user's push subscription from `user_notification` table.
|
|
||||||
/// Non-blocking: failures are logged but don't affect the caller.
|
|
||||||
pub fn send_push_to_user(&self, user_id: uuid::Uuid, payload: PushPayload) {
|
|
||||||
let push = self.push.clone();
|
|
||||||
let db = self.db.clone();
|
|
||||||
let log = self.logs.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Some(push) = push {
|
|
||||||
use models::users::user_notification;
|
|
||||||
use sea_orm::EntityTrait;
|
|
||||||
|
|
||||||
let prefs = user_notification::Entity::find_by_id(user_id)
|
|
||||||
.one(&db)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(Some(prefs)) = prefs {
|
|
||||||
if prefs.push_enabled
|
|
||||||
&& prefs.push_subscription_endpoint.is_some()
|
|
||||||
&& prefs.push_subscription_keys_p256dh.is_some()
|
|
||||||
&& prefs.push_subscription_keys_auth.is_some()
|
|
||||||
{
|
|
||||||
let endpoint = prefs.push_subscription_endpoint.unwrap();
|
|
||||||
let p256dh = prefs.push_subscription_keys_p256dh.unwrap();
|
|
||||||
let auth = prefs.push_subscription_keys_auth.unwrap();
|
|
||||||
|
|
||||||
if let Err(e) = push.send(&endpoint, &p256dh, &auth, &payload).await {
|
|
||||||
slog::warn!(log, "WebPush send failed"; "user_id" => %user_id, "error" => %e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Err(e) = prefs {
|
|
||||||
slog::warn!(log, "Failed to read push subscription"; "user_id" => %user_id, "error" => %e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppService {
|
impl AppService {
|
||||||
@ -148,42 +101,6 @@ impl AppService {
|
|||||||
|
|
||||||
let email = AppEmail::init(&config, logs.clone()).await?;
|
let email = AppEmail::init(&config, logs.clone()).await?;
|
||||||
let avatar = AppAvatar::init(&config).await?;
|
let avatar = AppAvatar::init(&config).await?;
|
||||||
let storage = match AppStorage::new(&config) {
|
|
||||||
Ok(s) => {
|
|
||||||
slog::info!(logs, "Storage initialized at {}", s.base_path.display());
|
|
||||||
Some(s)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
slog::warn!(logs, "Storage not available: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let push = match (
|
|
||||||
config.vapid_public_key(),
|
|
||||||
config.vapid_private_key(),
|
|
||||||
) {
|
|
||||||
(Some(public_key), Some(private_key)) => {
|
|
||||||
match WebPushService::new(
|
|
||||||
public_key,
|
|
||||||
private_key,
|
|
||||||
config.vapid_sender_email(),
|
|
||||||
) {
|
|
||||||
Ok(s) => {
|
|
||||||
slog::info!(logs, "WebPush initialized");
|
|
||||||
Some(s)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
slog::warn!(logs, "WebPush not available: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
slog::warn!(logs, "WebPush disabled — VAPID keys not configured");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build get_redis closure for MessageProducer
|
// Build get_redis closure for MessageProducer
|
||||||
let get_redis: Arc<
|
let get_redis: Arc<
|
||||||
@ -245,47 +162,6 @@ impl AppService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build push notification callback for RoomService
|
|
||||||
let push_fn: Option<room::PushNotificationFn> = push.clone().map(|push_svc| {
|
|
||||||
let db_clone = db.clone();
|
|
||||||
let log_clone = logs.clone();
|
|
||||||
Arc::new(move |user_id: uuid::Uuid, title: String, body: Option<String>, url: Option<String>| {
|
|
||||||
let push = push_svc.clone();
|
|
||||||
let db = db_clone.clone();
|
|
||||||
let log = log_clone.clone();
|
|
||||||
let payload = PushPayload {
|
|
||||||
title,
|
|
||||||
body: body.unwrap_or_default(),
|
|
||||||
url,
|
|
||||||
icon: None,
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use models::users::user_notification;
|
|
||||||
use sea_orm::EntityTrait;
|
|
||||||
|
|
||||||
let prefs = user_notification::Entity::find_by_id(user_id)
|
|
||||||
.one(&db)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(Some(prefs)) = prefs {
|
|
||||||
if prefs.push_enabled
|
|
||||||
&& prefs.push_subscription_endpoint.is_some()
|
|
||||||
&& prefs.push_subscription_keys_p256dh.is_some()
|
|
||||||
&& prefs.push_subscription_keys_auth.is_some()
|
|
||||||
{
|
|
||||||
let endpoint = prefs.push_subscription_endpoint.unwrap();
|
|
||||||
let p256dh = prefs.push_subscription_keys_p256dh.unwrap();
|
|
||||||
let auth = prefs.push_subscription_keys_auth.unwrap();
|
|
||||||
|
|
||||||
if let Err(e) = push.send(&endpoint, &p256dh, &auth, &payload).await {
|
|
||||||
slog::warn!(log, "WebPush send failed"; "user_id" => %user_id, "error" => %e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}) as room::PushNotificationFn
|
|
||||||
});
|
|
||||||
|
|
||||||
let room = RoomService::new(
|
let room = RoomService::new(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
cache.clone(),
|
cache.clone(),
|
||||||
@ -296,7 +172,6 @@ impl AppService {
|
|||||||
Some(task_service.clone()),
|
Some(task_service.clone()),
|
||||||
logs.clone(),
|
logs.clone(),
|
||||||
None,
|
None,
|
||||||
push_fn,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build WsTokenService
|
// Build WsTokenService
|
||||||
@ -312,8 +187,6 @@ impl AppService {
|
|||||||
room,
|
room,
|
||||||
ws_token,
|
ws_token,
|
||||||
queue_producer: message_producer,
|
queue_producer: message_producer,
|
||||||
storage,
|
|
||||||
push,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -324,40 +324,6 @@ impl AppService {
|
|||||||
if let Err(_e) = self.queue_producer.publish_email(envelope).await {
|
if let Err(_e) = self.queue_producer.publish_email(envelope).await {
|
||||||
// Failed to queue invitation email
|
// Failed to queue invitation email
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send in-app notification + push notification to the invitee
|
|
||||||
self.send_push_to_user(
|
|
||||||
target_uid,
|
|
||||||
crate::push::PushPayload {
|
|
||||||
title: format!("Project invitation: {}", project.name),
|
|
||||||
body: format!("{} invited you to join \"{}\" as {:?}", inviter.username, project.name, scope),
|
|
||||||
url: Some(format!("/projects/{}/invitations", project.name)),
|
|
||||||
icon: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = self
|
|
||||||
.room
|
|
||||||
.notification_create(room::NotificationCreateRequest {
|
|
||||||
notification_type: room::NotificationType::ProjectInvitation,
|
|
||||||
user_id: target_uid,
|
|
||||||
title: format!("{} invited you to join \"{}\"", inviter.username, project.name),
|
|
||||||
content: Some(format!("Role: {:?}", scope)),
|
|
||||||
room_id: None,
|
|
||||||
project_id: project.id,
|
|
||||||
related_message_id: None,
|
|
||||||
related_user_id: Some(inviter_uid),
|
|
||||||
related_room_id: None,
|
|
||||||
metadata: Some(serde_json::json!({
|
|
||||||
"project_id": project.id,
|
|
||||||
"project_name": project.name,
|
|
||||||
"inviter_uid": inviter_uid,
|
|
||||||
"scope": format!("{:?}", scope),
|
|
||||||
})),
|
|
||||||
expires_at: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
|
||||||
use base64ct::{Base64UrlUnpadded, Encoding};
|
|
||||||
use serde::Serialize;
|
|
||||||
use web_push_native::{
|
|
||||||
jwt_simple::algorithms::ES256KeyPair, p256::PublicKey, Auth, WebPushBuilder,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WebPushService {
|
|
||||||
http: reqwest::Client,
|
|
||||||
vapid_key_pair: Arc<ES256KeyPair>,
|
|
||||||
sender_email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct PushPayload {
|
|
||||||
pub title: String,
|
|
||||||
pub body: String,
|
|
||||||
pub url: Option<String>,
|
|
||||||
pub icon: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebPushService {
|
|
||||||
/// Create a new WebPush service with VAPID keys.
|
|
||||||
/// - `_vapid_public_key`: Base64url-encoded P-256 public key (derived from private key)
|
|
||||||
/// - `vapid_private_key`: Base64url-encoded P-256 private key
|
|
||||||
/// - `sender_email`: Contact email for VAPID (e.g. "mailto:admin@example.com")
|
|
||||||
pub fn new(
|
|
||||||
_vapid_public_key: String,
|
|
||||||
vapid_private_key: String,
|
|
||||||
sender_email: String,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
// The VAPID private key bytes are used to create the ES256KeyPair.
|
|
||||||
// The public key is derived from it, so we don't need to validate the public key separately.
|
|
||||||
let key_bytes = Base64UrlUnpadded::decode_vec(&vapid_private_key)
|
|
||||||
.context("Failed to decode VAPID private key")?;
|
|
||||||
let vapid_key_pair =
|
|
||||||
ES256KeyPair::from_bytes(&key_bytes).context("Invalid VAPID private key")?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
http: reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.context("Failed to build HTTP client")?,
|
|
||||||
vapid_key_pair: Arc::new(vapid_key_pair),
|
|
||||||
sender_email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a push notification to a browser subscription.
|
|
||||||
pub async fn send(
|
|
||||||
&self,
|
|
||||||
endpoint: &str,
|
|
||||||
p256dh: &str,
|
|
||||||
auth: &str,
|
|
||||||
payload: &PushPayload,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let endpoint_uri: http::Uri = endpoint
|
|
||||||
.parse()
|
|
||||||
.with_context(|| format!("Invalid endpoint URL: {}", endpoint))?;
|
|
||||||
|
|
||||||
let ua_public_bytes = Base64UrlUnpadded::decode_vec(p256dh)
|
|
||||||
.with_context(|| format!("Failed to decode p256dh: {}", p256dh))?;
|
|
||||||
let ua_public = PublicKey::from_sec1_bytes(&ua_public_bytes)
|
|
||||||
.with_context(|| "Invalid p256dh key")?;
|
|
||||||
|
|
||||||
let auth_bytes = Base64UrlUnpadded::decode_vec(auth)
|
|
||||||
.with_context(|| format!("Failed to decode auth: {}", auth))?;
|
|
||||||
let ua_auth = Auth::clone_from_slice(&auth_bytes);
|
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(payload)?;
|
|
||||||
|
|
||||||
let request = WebPushBuilder::new(endpoint_uri, ua_public, ua_auth)
|
|
||||||
.with_vapid(&self.vapid_key_pair, &self.sender_email)
|
|
||||||
.build(payload_bytes)?;
|
|
||||||
|
|
||||||
let reqwest_request = reqwest::Request::try_from(request)
|
|
||||||
.context("Failed to convert web-push request")?;
|
|
||||||
let response = self.http.execute(reqwest_request).await?;
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() && status.as_u16() != 201 {
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
bail!("WebPush failed: {} - {}", status, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
use config::AppConfig;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppStorage {
|
|
||||||
pub base_path: PathBuf,
|
|
||||||
pub public_url_base: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppStorage {
|
|
||||||
pub fn new(config: &AppConfig) -> anyhow::Result<Self> {
|
|
||||||
let base_path = config
|
|
||||||
.env
|
|
||||||
.get("STORAGE_PATH")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/data/files"));
|
|
||||||
|
|
||||||
let public_url_base = config
|
|
||||||
.env
|
|
||||||
.get("STORAGE_PUBLIC_URL")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "/files".to_string());
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
base_path,
|
|
||||||
public_url_base,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data to a local path and return the public URL.
|
|
||||||
pub async fn upload(
|
|
||||||
&self,
|
|
||||||
key: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
let path = self.base_path.join(key);
|
|
||||||
|
|
||||||
// Create parent directories
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::fs::write(&path, &data).await?;
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"{}/{}",
|
|
||||||
self.public_url_base.trim_end_matches('/'),
|
|
||||||
key
|
|
||||||
);
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(&self, key: &str) -> anyhow::Result<()> {
|
|
||||||
let path = self.base_path.join(key);
|
|
||||||
if path.exists() {
|
|
||||||
tokio::fs::remove_file(&path).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -19,12 +19,6 @@ pub struct NotificationPreferencesParams {
|
|||||||
pub marketing_enabled: Option<bool>,
|
pub marketing_enabled: Option<bool>,
|
||||||
pub security_enabled: Option<bool>,
|
pub security_enabled: Option<bool>,
|
||||||
pub product_enabled: Option<bool>,
|
pub product_enabled: Option<bool>,
|
||||||
/// Web Push subscription endpoint (set to null to unsubscribe)
|
|
||||||
pub push_subscription_endpoint: Option<String>,
|
|
||||||
/// Web Push subscription p256dh key
|
|
||||||
pub push_subscription_keys_p256dh: Option<String>,
|
|
||||||
/// Web Push subscription auth key
|
|
||||||
pub push_subscription_keys_auth: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
@ -127,18 +121,6 @@ impl AppService {
|
|||||||
if let Some(product_enabled) = params.product_enabled {
|
if let Some(product_enabled) = params.product_enabled {
|
||||||
active_prefs.product_enabled = Set(product_enabled);
|
active_prefs.product_enabled = Set(product_enabled);
|
||||||
}
|
}
|
||||||
if let Some(endpoint) = params.push_subscription_endpoint.clone() {
|
|
||||||
if endpoint.is_empty() {
|
|
||||||
// Empty string means unsubscribe — clear all subscription fields
|
|
||||||
active_prefs.push_subscription_endpoint = Set(None);
|
|
||||||
active_prefs.push_subscription_keys_p256dh = Set(None);
|
|
||||||
active_prefs.push_subscription_keys_auth = Set(None);
|
|
||||||
} else {
|
|
||||||
active_prefs.push_subscription_endpoint = Set(Some(endpoint));
|
|
||||||
active_prefs.push_subscription_keys_p256dh = Set(params.push_subscription_keys_p256dh.clone());
|
|
||||||
active_prefs.push_subscription_keys_auth = Set(params.push_subscription_keys_auth.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
active_prefs.updated_at = Set(Utc::now());
|
active_prefs.updated_at = Set(Utc::now());
|
||||||
|
|
||||||
active_prefs.update(&self.db).await?
|
active_prefs.update(&self.db).await?
|
||||||
@ -158,9 +140,6 @@ impl AppService {
|
|||||||
marketing_enabled: Set(params.marketing_enabled.unwrap_or(false)),
|
marketing_enabled: Set(params.marketing_enabled.unwrap_or(false)),
|
||||||
security_enabled: Set(params.security_enabled.unwrap_or(true)),
|
security_enabled: Set(params.security_enabled.unwrap_or(true)),
|
||||||
product_enabled: Set(params.product_enabled.unwrap_or(false)),
|
product_enabled: Set(params.product_enabled.unwrap_or(false)),
|
||||||
push_subscription_endpoint: Set(None),
|
|
||||||
push_subscription_keys_p256dh: Set(None),
|
|
||||||
push_subscription_keys_auth: Set(None),
|
|
||||||
created_at: Set(Utc::now()),
|
created_at: Set(Utc::now()),
|
||||||
updated_at: Set(Utc::now()),
|
updated_at: Set(Utc::now()),
|
||||||
};
|
};
|
||||||
@ -210,9 +189,6 @@ impl AppService {
|
|||||||
marketing_enabled: Set(false),
|
marketing_enabled: Set(false),
|
||||||
security_enabled: Set(true),
|
security_enabled: Set(true),
|
||||||
product_enabled: Set(false),
|
product_enabled: Set(false),
|
||||||
push_subscription_endpoint: Set(None),
|
|
||||||
push_subscription_keys_p256dh: Set(None),
|
|
||||||
push_subscription_keys_auth: Set(None),
|
|
||||||
created_at: Set(Utc::now()),
|
created_at: Set(Utc::now()),
|
||||||
updated_at: Set(Utc::now()),
|
updated_at: Set(Utc::now()),
|
||||||
};
|
};
|
||||||
@ -221,26 +197,4 @@ impl AppService {
|
|||||||
|
|
||||||
Ok(NotificationPreferencesResponse::from(created_prefs))
|
Ok(NotificationPreferencesResponse::from(created_prefs))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_unsubscribe_push(
|
|
||||||
&self,
|
|
||||||
context: &Session,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
|
||||||
|
|
||||||
let prefs = user_notification::Entity::find_by_id(user_uid)
|
|
||||||
.one(&self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(prefs) = prefs {
|
|
||||||
let mut active_prefs: user_notification::ActiveModel = prefs.into();
|
|
||||||
active_prefs.push_subscription_endpoint = Set(None);
|
|
||||||
active_prefs.push_subscription_keys_p256dh = Set(None);
|
|
||||||
active_prefs.push_subscription_keys_auth = Set(None);
|
|
||||||
active_prefs.updated_at = Set(Utc::now());
|
|
||||||
active_prefs.update(&self.db).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -373,40 +373,6 @@ impl AppService {
|
|||||||
self.email.send(envelope).await.map_err(|e| {
|
self.email.send(envelope).await.map_err(|e| {
|
||||||
AppError::InternalServerError(format!("Failed to send invitation email: {}", e))
|
AppError::InternalServerError(format!("Failed to send invitation email: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Send in-app notification + push notification to the invitee
|
|
||||||
self.send_push_to_user(
|
|
||||||
target_user.uid,
|
|
||||||
crate::push::PushPayload {
|
|
||||||
title: format!("Workspace invitation: {}", ws.name),
|
|
||||||
body: format!("{} invited you to join the workspace \"{}\"", inviter.username, ws.name),
|
|
||||||
url: Some(format!("/workspaces/{}/invitations", ws.slug)),
|
|
||||||
icon: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = self
|
|
||||||
.room
|
|
||||||
.notification_create(room::NotificationCreateRequest {
|
|
||||||
notification_type: room::NotificationType::WorkspaceInvitation,
|
|
||||||
user_id: target_user.uid,
|
|
||||||
title: format!("{} invited you to join \"{}\"", inviter.username, ws.name),
|
|
||||||
content: None,
|
|
||||||
room_id: None,
|
|
||||||
project_id: Default::default(), // workspace invitations don't have a project_id
|
|
||||||
related_message_id: None,
|
|
||||||
related_user_id: Some(user_uid),
|
|
||||||
related_room_id: None,
|
|
||||||
metadata: Some(serde_json::json!({
|
|
||||||
"workspace_id": ws.id,
|
|
||||||
"workspace_name": ws.name,
|
|
||||||
"workspace_slug": ws.slug,
|
|
||||||
"inviter_uid": user_uid,
|
|
||||||
})),
|
|
||||||
expires_at: None,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@gitgraph/react": "^1.6.0",
|
"@gitgraph/react": "^1.6.0",
|
||||||
"@lobehub/icons": "^5.4.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@tanstack/react-query": "^5.96.0",
|
"@tanstack/react-query": "^5.96.0",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
@ -30,7 +29,6 @@
|
|||||||
"@tiptap/starter-kit": "^3.22.3",
|
"@tiptap/starter-kit": "^3.22.3",
|
||||||
"@tiptap/suggestion": "^3.22.3",
|
"@tiptap/suggestion": "^3.22.3",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"browser-image-compression": "^2.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
4051
pnpm-lock.yaml
generated
4051
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
51
public/sw.js
51
public/sw.js
@ -1,51 +0,0 @@
|
|||||||
// Service Worker for Web Push Notifications
|
|
||||||
const CACHE_NAME = 'app-v1';
|
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
if (!event.data) return;
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = event.data.json();
|
|
||||||
} catch {
|
|
||||||
data = { title: 'Notification', body: event.data.text() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
body: data.body || '',
|
|
||||||
icon: data.icon || '/icon.png',
|
|
||||||
badge: '/badge.png',
|
|
||||||
data: { url: data.url || '/' },
|
|
||||||
vibrate: [200, 100, 200],
|
|
||||||
requireInteraction: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title || 'App Notification', options)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
|
|
||||||
const url = event.notification.data?.url || '/';
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
|
||||||
for (const client of clients) {
|
|
||||||
if (client.url === url && 'focus' in client) {
|
|
||||||
return client.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return self.clients.openWindow(url);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(self.clients.claim());
|
|
||||||
});
|
|
||||||
814
room.md
814
room.md
@ -1,814 +0,0 @@
|
|||||||
# Room 模块设计文档
|
|
||||||
|
|
||||||
## 1. 概述
|
|
||||||
|
|
||||||
`Room` 模块是本系统核心的协作与消息通信功能模块,提供稳定、高效、可扩展的实时消息平台。系统采用 Rust (Actix-web) 后端 + React 19 前端的技术栈,通过 WebSocket 实现实时通信,集成了 AI 代理进行智能协作。
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
- **后端**: Rust + Actix-web + SeaORM + Redis + NATS
|
|
||||||
- **前端**: React 19 + TypeScript + Tailwind CSS
|
|
||||||
- **实时通信**: WebSocket (自定义协议)
|
|
||||||
- **消息队列**: NATS (事件分发)
|
|
||||||
- **缓存**: Redis (序列号管理、去重)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 数据模型
|
|
||||||
|
|
||||||
### 2.1 核心表结构
|
|
||||||
|
|
||||||
#### `room` - 房间表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID (v7) | 主键,时间排序 |
|
|
||||||
| project | UUID | 所属项目 |
|
|
||||||
| room_name | VARCHAR(128) | 房间名称 |
|
|
||||||
| public | BOOLEAN | 是否公开房间 |
|
|
||||||
| category | UUID (nullable) | 所属分类 |
|
|
||||||
| created_by | UUID | 创建者 |
|
|
||||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
|
||||||
| last_msg_at | TIMESTAMPTZ | 最后消息时间 |
|
|
||||||
|
|
||||||
#### `room_message` - 消息表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID (v7) | 主键 |
|
|
||||||
| seq | BIGINT | 全局序列号(递增) |
|
|
||||||
| room | UUID | 所属房间 |
|
|
||||||
| sender_type | ENUM | `member` / `ai` / `system` |
|
|
||||||
| sender_id | UUID (nullable) | 发送者 ID |
|
|
||||||
| thread | UUID (nullable) | 所属线程 |
|
|
||||||
| in_reply_to | UUID (nullable) | 回复的消息 ID |
|
|
||||||
| content | TEXT | 消息内容 |
|
|
||||||
| content_type | ENUM | `text` / `markdown` / `code` / `mention` |
|
|
||||||
| edited_at | TIMESTAMPTZ (nullable) | 编辑时间 |
|
|
||||||
| send_at | TIMESTAMPTZ | 发送时间 |
|
|
||||||
| revoked | TIMESTAMPTZ (nullable) | 撤回时间 |
|
|
||||||
| revoked_by | UUID (nullable) | 撤回操作者 |
|
|
||||||
|
|
||||||
#### `room_member` - 房间成员表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| room | UUID | 房间 ID |
|
|
||||||
| user | UUID | 用户 ID |
|
|
||||||
| role | ENUM | `Owner` / `Admin` / `Member` |
|
|
||||||
| first_msg_in | TIMESTAMPTZ (nullable) | 首次发消息时间 |
|
|
||||||
| joined_at | TIMESTAMPTZ (nullable) | 加入时间 |
|
|
||||||
| last_read_seq | BIGINT (nullable) | 已读序列号 |
|
|
||||||
| do_not_disturb | BOOLEAN | 免打扰 |
|
|
||||||
| dnd_start_hour | INT (nullable) | DND 开始小时 |
|
|
||||||
| dnd_end_hour | INT (nullable) | DND 结束小时 |
|
|
||||||
|
|
||||||
#### `room_category` - 频道分类表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID | 主键 |
|
|
||||||
| project | UUID | 所属项目 |
|
|
||||||
| name | VARCHAR(64) | 分类名称 |
|
|
||||||
| position | INT | 排序位置 |
|
|
||||||
|
|
||||||
#### `room_thread` - 线程表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID | 主键 |
|
|
||||||
| room | UUID | 所属房间 |
|
|
||||||
| parent_message | UUID | 父消息 ID |
|
|
||||||
| created_by | UUID | 创建者 |
|
|
||||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
|
||||||
|
|
||||||
#### `room_ai` - AI 配置表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| room | UUID | 房间 ID |
|
|
||||||
| model | UUID | AI 模型 ID |
|
|
||||||
| version | VARCHAR (nullable) | 模型版本 |
|
|
||||||
| call_count | INT | 调用次数 |
|
|
||||||
| last_call_at | TIMESTAMPTZ (nullable) | 最后调用时间 |
|
|
||||||
| history_limit | INT | 上下文历史限制 |
|
|
||||||
| system_prompt | TEXT | 系统提示词 |
|
|
||||||
| temperature | FLOAT | 温度参数 |
|
|
||||||
| max_tokens | INT | 最大 token 数 |
|
|
||||||
| use_exact | BOOLEAN | 精确模式 |
|
|
||||||
| think | BOOLEAN | 思考模式 |
|
|
||||||
| stream | BOOLEAN | 流式输出 |
|
|
||||||
| min_score | FLOAT (nullable) | 最小分数阈值 |
|
|
||||||
|
|
||||||
#### `room_message_reaction` - 消息反应表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID | 主键 |
|
|
||||||
| message | UUID | 消息 ID |
|
|
||||||
| user | UUID | 用户 ID |
|
|
||||||
| emoji | VARCHAR(32) | emoji |
|
|
||||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
|
||||||
|
|
||||||
#### `room_pin` - 置顶消息表
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| id | UUID | 主键 |
|
|
||||||
| room | UUID | 房间 ID |
|
|
||||||
| message | UUID | 消息 ID |
|
|
||||||
| pinned_by | UUID | 置顶者 |
|
|
||||||
| pinned_at | TIMESTAMPTZ | 置顶时间 |
|
|
||||||
|
|
||||||
### 2.2 消息内容类型
|
|
||||||
```typescript
|
|
||||||
type MessageContentType = 'text' | 'markdown' | 'code' | 'mention';
|
|
||||||
|
|
||||||
type MessageSenderType = 'member' | 'ai' | 'system';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. WebSocket 通信协议
|
|
||||||
|
|
||||||
### 3.1 协议格式
|
|
||||||
|
|
||||||
#### 请求
|
|
||||||
```typescript
|
|
||||||
interface WsRequest {
|
|
||||||
type: 'request';
|
|
||||||
request_id: string; // 唯一请求 ID
|
|
||||||
action: WsAction; // 操作类型
|
|
||||||
params?: WsRequestParams;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 响应
|
|
||||||
```typescript
|
|
||||||
interface WsResponse {
|
|
||||||
type: 'response';
|
|
||||||
request_id: string;
|
|
||||||
action: string;
|
|
||||||
data?: WsResponseData;
|
|
||||||
error?: WsError;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Action 列表
|
|
||||||
|
|
||||||
#### 房间管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `room.list` | 获取房间列表 | Member |
|
|
||||||
| `room.get` | 获取房间详情 | Member |
|
|
||||||
| `room.create` | 创建房间 | Admin |
|
|
||||||
| `room.update` | 更新房间 | Admin |
|
|
||||||
| `room.delete` | 删除房间 | Admin |
|
|
||||||
| `room.subscribe` | 订阅房间事件 | Member |
|
|
||||||
| `room.unsubscribe` | 取消订阅 | Member |
|
|
||||||
|
|
||||||
#### 消息管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `message.list` | 获取消息列表(分页) | Member |
|
|
||||||
| `message.create` | 发送消息 | Member |
|
|
||||||
| `message.update` | 编辑消息 | Owner |
|
|
||||||
| `message.revoke` | 撤回消息 | Owner |
|
|
||||||
| `message.get` | 获取单条消息 | Member |
|
|
||||||
| `message.search` | 搜索消息 | Member |
|
|
||||||
|
|
||||||
#### 成员管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `member.list` | 获取成员列表 | Member |
|
|
||||||
| `member.add` | 添加成员 | Admin |
|
|
||||||
| `member.remove` | 移除成员 | Admin |
|
|
||||||
| `member.leave` | 离开房间 | Member |
|
|
||||||
| `member.update_role` | 更新角色 | Admin |
|
|
||||||
| `member.set_read_seq` | 设置已读位置 | Member |
|
|
||||||
|
|
||||||
#### 分类管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `category.list` | 获取分类列表 | Member |
|
|
||||||
| `category.create` | 创建分类 | Admin |
|
|
||||||
| `category.update` | 更新分类 | Admin |
|
|
||||||
| `category.delete` | 删除分类 | Admin |
|
|
||||||
|
|
||||||
#### 线程管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `thread.list` | 获取线程列表 | Member |
|
|
||||||
| `thread.create` | 创建线程 | Member |
|
|
||||||
| `thread.messages` | 获取线程消息 | Member |
|
|
||||||
|
|
||||||
#### 反应管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `reaction.add` | 添加反应 | Member |
|
|
||||||
| `reaction.remove` | 移除反应 | Owner |
|
|
||||||
| `reaction.list_batch` | 批量获取反应 | Member |
|
|
||||||
|
|
||||||
#### 置顶管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `pin.list` | 获取置顶列表 | Member |
|
|
||||||
| `pin.add` | 添加置顶 | Admin |
|
|
||||||
| `pin.remove` | 移除置顶 | Admin |
|
|
||||||
|
|
||||||
#### AI 管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `ai.list` | 获取 AI 配置列表 | Member |
|
|
||||||
| `ai.upsert` | 创建/更新 AI 配置 | Admin |
|
|
||||||
| `ai.delete` | 删除 AI 配置 | Admin |
|
|
||||||
|
|
||||||
#### 通知管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `notification.list` | 获取通知列表 | Member |
|
|
||||||
| `notification.mark_read` | 标记已读 | Member |
|
|
||||||
| `notification.mark_all_read` | 全部标记已读 | Member |
|
|
||||||
| `notification.archive` | 归档通知 | Member |
|
|
||||||
|
|
||||||
#### 提及管理
|
|
||||||
| Action | 说明 | 权限 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `mention.list` | 获取提及列表 | Member |
|
|
||||||
| `mention.read_all` | 全部标记已读 | Member |
|
|
||||||
|
|
||||||
### 3.3 实时事件推送
|
|
||||||
```typescript
|
|
||||||
type WsEventType =
|
|
||||||
| 'room.created'
|
|
||||||
| 'room.updated'
|
|
||||||
| 'room.deleted'
|
|
||||||
| 'message.created'
|
|
||||||
| 'message.updated'
|
|
||||||
| 'message.revoked'
|
|
||||||
| 'member.joined'
|
|
||||||
| 'member.left'
|
|
||||||
| 'member.role_changed'
|
|
||||||
| 'thread.created'
|
|
||||||
| 'reaction.updated'
|
|
||||||
| 'pin.updated';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 后端架构
|
|
||||||
|
|
||||||
### 4.1 目录结构
|
|
||||||
```
|
|
||||||
libs/room/src/
|
|
||||||
├── lib.rs # 模块入口
|
|
||||||
├── service.rs # RoomService 主服务
|
|
||||||
├── room.rs # 房间 CRUD
|
|
||||||
├── message.rs # 消息管理
|
|
||||||
├── member.rs # 成员管理
|
|
||||||
├── category.rs # 分类管理
|
|
||||||
├── thread.rs # 线程管理
|
|
||||||
├── reaction.rs # 反应管理
|
|
||||||
├── pin.rs # 置顶管理
|
|
||||||
├── ai.rs # AI 配置管理
|
|
||||||
├── notification.rs # 通知管理
|
|
||||||
├── search.rs # 搜索功能
|
|
||||||
├── connection.rs # WebSocket 连接管理
|
|
||||||
├── room_ai_queue.rs # AI 队列锁
|
|
||||||
├── error.rs # 错误类型
|
|
||||||
├── types.rs # 请求/响应类型
|
|
||||||
├── helpers.rs # 辅助函数
|
|
||||||
├── metrics.rs # 指标收集
|
|
||||||
├── ws_context.rs # WebSocket 上下文
|
|
||||||
└── draft_and_history.rs # 草稿和编辑历史
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 核心服务 RoomService
|
|
||||||
```rust
|
|
||||||
pub struct RoomService {
|
|
||||||
db: DatabaseConnection,
|
|
||||||
cache: AppCache,
|
|
||||||
queue: NatsContext,
|
|
||||||
ai_client: AiClient,
|
|
||||||
rate_limiter: RateLimiter,
|
|
||||||
log: Logger,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 并发控制
|
|
||||||
- **物理并发限制**: `tokio::sync::Semaphore`
|
|
||||||
- **消息序列号**: Redis INCR 原子递增
|
|
||||||
- **消息去重**: `DashMap<UUID, HashSet<i64>>` 内存缓存
|
|
||||||
- **AI 队列锁**: Redis 分布式锁 (`ai:room:queue:lock:{room_id}`)
|
|
||||||
|
|
||||||
### 4.4 AI 队列机制
|
|
||||||
```rust
|
|
||||||
// Redis 键结构
|
|
||||||
ai:room:queue:{room_id} // 队列
|
|
||||||
ai:room:queue:seq:{room_id} // 序号
|
|
||||||
ai:room:queue:lock:{room_id} // 分布式锁
|
|
||||||
ai:room:queue:ticket:{room_id}:{ticket_id} // 票据
|
|
||||||
|
|
||||||
// 锁参数
|
|
||||||
LOCK_TTL_MS: 120_000 // 锁超时 2 分钟
|
|
||||||
TICKET_TTL_MS: 90_000 // 票据超时 1.5 分钟
|
|
||||||
MAX_BACKOFF_MS: 200 // 最大退避 200ms
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 定时任务
|
|
||||||
- **空闲房间清理**: 30 天无消息的房间标记为归档
|
|
||||||
- **频率限制刷新**: 每分钟重置计数
|
|
||||||
- **陈旧指标清理**: 定期清理过期数据
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 前端架构
|
|
||||||
|
|
||||||
### 5.1 目录结构
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/room/
|
|
||||||
│ ├── DiscordServerSidebar.tsx # 服务器图标侧边栏 (72px)
|
|
||||||
│ ├── DiscordChannelSidebar.tsx # 可折叠频道列表
|
|
||||||
│ ├── DiscordChatPanel.tsx # 主聊天面板
|
|
||||||
│ ├── DiscordMemberList.tsx # 成员列表 (带在线状态)
|
|
||||||
│ ├── message/
|
|
||||||
│ │ ├── MessageList.tsx # 消息列表
|
|
||||||
│ │ ├── MessageInput.tsx # 消息输入框
|
|
||||||
│ │ ├── MessageBubble.tsx # 消息气泡
|
|
||||||
│ │ └── MessageActions.tsx # 消息操作菜单
|
|
||||||
│ ├── RoomThreadPanel.tsx # 线程侧边栏
|
|
||||||
│ ├── RoomMentionPanel.tsx # 提及面板
|
|
||||||
│ ├── RoomPinBar.tsx # 置顶栏
|
|
||||||
│ ├── RoomMessageSearch.tsx # 消息搜索
|
|
||||||
│ ├── RoomSettingsPanel.tsx # 设置面板
|
|
||||||
│ ├── RoomAiAuthBanner.tsx # AI 认证提示
|
|
||||||
│ ├── RoomAiTasksPanel.tsx # AI 任务面板
|
|
||||||
│ └── ...
|
|
||||||
├── contexts/
|
|
||||||
│ └── room-context.tsx # 房间状态管理
|
|
||||||
└── lib/
|
|
||||||
└── ws-protocol.ts # WebSocket 协议定义
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 React Context 状态管理
|
|
||||||
```typescript
|
|
||||||
interface RoomContextValue {
|
|
||||||
// 房间状态
|
|
||||||
rooms: RoomResponse[];
|
|
||||||
currentRoom: RoomResponse | null;
|
|
||||||
roomsLoading: boolean;
|
|
||||||
|
|
||||||
// 消息状态
|
|
||||||
messages: MessageWithMeta[];
|
|
||||||
threads: RoomThreadResponse[];
|
|
||||||
|
|
||||||
// 成员状态
|
|
||||||
members: RoomMember[];
|
|
||||||
membersLoading: boolean;
|
|
||||||
|
|
||||||
// AI 配置
|
|
||||||
roomAiConfigs: RoomAiResponse[];
|
|
||||||
|
|
||||||
// WebSocket 状态
|
|
||||||
wsStatus: 'open' | 'connecting' | 'disconnected';
|
|
||||||
wsError: Error | null;
|
|
||||||
wsClient: WebSocketClient | null;
|
|
||||||
|
|
||||||
// 操作方法
|
|
||||||
sendMessage: (content: string, inReplyTo?: string) => Promise<void>;
|
|
||||||
editMessage: (messageId: string, content: string) => Promise<void>;
|
|
||||||
revokeMessage: (messageId: string) => Promise<void>;
|
|
||||||
updateRoom: (roomId: string, data: Partial<RoomUpdateRequest>) => Promise<void>;
|
|
||||||
refreshThreads: () => Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Discord 风格 UI 布局
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ [Icon] │ # channel-name [🔍][👤] │
|
|
||||||
├──────────┼─────────────────────────────────────────┬────────────┤
|
|
||||||
│ │ ┌─────────────────────────────────┐ │ │
|
|
||||||
│ Category │ │ Message List │ │ Members │
|
|
||||||
│ ────────│ │ (with virtual scrolling) │ │ Online(3) │
|
|
||||||
│ # gen │ │ │ │ ● user1 │
|
|
||||||
│ # dev │ │ user1 [10:30] Hello! │ │ ● user2 │
|
|
||||||
│ # ai │ │ ↳ user2 [10:32] Hi! │ │ │
|
|
||||||
│ │ │ │ │ Offline(5)│
|
|
||||||
│ Category │ │ 🤖 AI [10:33] Thinking... │ │ ○ user3 │
|
|
||||||
│ ────────│ │ │ │ │
|
|
||||||
│ 🔒 priv │ └─────────────────────────────────┘ │ │
|
|
||||||
│ ├─────────────────────────────────────────┤ │
|
|
||||||
│ │ [+] Type a message... [@][📎] │ │
|
|
||||||
└──────────┴─────────────────────────────────────────┴────────────┘
|
|
||||||
72px Flex: 1 240px
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 消息数据流
|
|
||||||
```
|
|
||||||
用户输入 → MessageInput
|
|
||||||
↓
|
|
||||||
WebSocket.send({ action: 'message.create', params: { content, room_id } })
|
|
||||||
↓
|
|
||||||
后端处理 → Redis INCR seq → DB insert → NATS publish
|
|
||||||
↓
|
|
||||||
WebSocket.push({ event: 'message.created', data: message })
|
|
||||||
↓
|
|
||||||
RoomContext.handleMessage() → setMessages(prev => [...prev, message])
|
|
||||||
↓
|
|
||||||
MessageList 渲染
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. API 端点 (REST)
|
|
||||||
|
|
||||||
### 6.1 房间管理
|
|
||||||
```
|
|
||||||
GET /api/projects/{project}/rooms # 房间列表
|
|
||||||
GET /api/projects/{project}/rooms/{room} # 房间详情
|
|
||||||
POST /api/projects/{project}/rooms # 创建房间
|
|
||||||
PATCH /api/projects/{project}/rooms/{room} # 更新房间
|
|
||||||
DELETE /api/projects/{project}/rooms/{room} # 删除房间
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 消息管理
|
|
||||||
```
|
|
||||||
GET /api/rooms/{room}/messages # 消息列表 (分页)
|
|
||||||
GET /api/rooms/{room}/messages/{message} # 单条消息
|
|
||||||
POST /api/rooms/{room}/messages # 发送消息
|
|
||||||
PATCH /api/rooms/{room}/messages/{message} # 编辑消息
|
|
||||||
DELETE /api/rooms/{room}/messages/{message} # 撤回消息
|
|
||||||
GET /api/rooms/{room}/messages/search # 搜索消息
|
|
||||||
GET /api/rooms/{room}/messages/{message}/history # 编辑历史
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 AI 端点
|
|
||||||
```
|
|
||||||
GET /api/rooms/{room}/ai # AI 配置列表
|
|
||||||
POST /api/rooms/{room}/ai # 创建 AI 配置
|
|
||||||
PATCH /api/rooms/{room}/ai/{model} # 更新 AI 配置
|
|
||||||
DELETE /api/rooms/{room}/ai/{model} # 删除 AI 配置
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 关键设计原则
|
|
||||||
|
|
||||||
| 原则 | 实现方式 |
|
|
||||||
|------|----------|
|
|
||||||
| 高内聚低耦合 | 模块化服务层、清晰的领域边界 |
|
|
||||||
| 异步非阻塞 | Tokio async/await、Redis 异步客户端 |
|
|
||||||
| 事件驱动 | NATS Pub/Sub 解耦组件 |
|
|
||||||
| 强类型安全 | TypeScript + Rust 编译期检查 |
|
|
||||||
| 分层架构 | Service → Repository → Database |
|
|
||||||
| 鲁棒性优先 | 完善的错误处理、限流、资源回收 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 已实现功能清单
|
|
||||||
|
|
||||||
### 8.1 核心功能 ✅
|
|
||||||
- [x] 房间 CRUD (创建/读取/更新/删除)
|
|
||||||
- [x] 消息 CRUD (发送/编辑/撤回/历史)
|
|
||||||
- [x] 成员管理 (邀请/移除/角色变更)
|
|
||||||
- [x] 频道分类 (创建/排序/折叠)
|
|
||||||
- [x] 线程回复 (创建线程/线程消息)
|
|
||||||
- [x] 消息反应 (emoji 反应)
|
|
||||||
- [x] 消息置顶
|
|
||||||
- [x] 消息搜索
|
|
||||||
- [x] 未读计数
|
|
||||||
|
|
||||||
### 8.2 实时功能 ✅
|
|
||||||
- [x] WebSocket 长连接
|
|
||||||
- [x] 消息实时推送
|
|
||||||
- [x] 成员状态同步
|
|
||||||
- [x] 在线状态显示
|
|
||||||
- [x] 消息去重
|
|
||||||
- [x] IndexedDB 离线缓存
|
|
||||||
- [x] 虚拟滚动列表 (@tanstack/react-virtual)
|
|
||||||
|
|
||||||
### 8.3 AI 集成 ✅
|
|
||||||
- [x] AI 模型配置
|
|
||||||
- [x] AI 消息发送
|
|
||||||
- [x] 系统提示词
|
|
||||||
- [x] 上下文历史限制
|
|
||||||
- [x] 流式输出支持
|
|
||||||
- [x] AI 队列锁
|
|
||||||
|
|
||||||
### 8.4 富媒体消息 ✅
|
|
||||||
- [x] 消息内容类型: text, image, audio, video, file
|
|
||||||
- [x] Tiptap 富文本编辑器 (IMEditor)
|
|
||||||
- [x] FileNode Tiptap 扩展 (文件/图片节点)
|
|
||||||
- [x] 文件上传状态管理 (uploading/done/error)
|
|
||||||
- [x] Markdown 支持
|
|
||||||
|
|
||||||
### 8.5 全文搜索 ✅
|
|
||||||
- [x] PostgreSQL 全文索引 (GIN + tsvector)
|
|
||||||
- [x] 搜索 API (room_message_search)
|
|
||||||
- [x] 分页与结果计数
|
|
||||||
|
|
||||||
### 8.6 通知系统 ✅
|
|
||||||
- [x] 提及通知
|
|
||||||
- [x] 线程通知
|
|
||||||
- [x] DND 免打扰时段 (do_not_disturb, dnd_start/end_hour)
|
|
||||||
|
|
||||||
### 8.4 用户体验 ✅
|
|
||||||
- [x] 消息草稿自动保存
|
|
||||||
- [x] @提及功能
|
|
||||||
- [x] 回复引用
|
|
||||||
- [x] 消息时间格式化
|
|
||||||
- [x] Discord 风格 UI
|
|
||||||
- [x] 侧边栏折叠
|
|
||||||
- [x] 成员列表按角色着色
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 已完成功能 (详细技术方案)
|
|
||||||
|
|
||||||
### 9.1 富媒体消息支持 ✅
|
|
||||||
|
|
||||||
#### 9.1.1 图片消息
|
|
||||||
```
|
|
||||||
技术实现:
|
|
||||||
- 前端: FileNode.tsx Tiptap 扩展,支持 inline 文件节点
|
|
||||||
- 富媒体消息类型: MessageContentType::Image, Audio, Video, File
|
|
||||||
- 文件上传: Tiptap IMEditor 支持拖拽上传
|
|
||||||
- 状态管理: uploading/done/error 三种状态
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- src/components/room/message/editor/FileNode.tsx (Tiptap 文件节点)
|
|
||||||
- src/components/room/message/editor/IMEditor.tsx (富文本编辑器)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 9.1.2 消息内容类型 ✅
|
|
||||||
```
|
|
||||||
已实现的消息类型:
|
|
||||||
- Text (text)
|
|
||||||
- Image (image)
|
|
||||||
- Audio (audio)
|
|
||||||
- Video (video)
|
|
||||||
- File (file)
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- libs/models/rooms/mod.rs (MessageContentType enum)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 历史消息优化 ✅
|
|
||||||
|
|
||||||
#### 9.2.1 虚拟滚动列表
|
|
||||||
```
|
|
||||||
技术实现:
|
|
||||||
- @tanstack/react-virtual + useVirtualizer
|
|
||||||
- 按需渲染可见区域消息 (overscan: 30)
|
|
||||||
- 动态高度估算 (estimateMessageRowHeight)
|
|
||||||
- 日期分隔符自动插入
|
|
||||||
- 滚动位置保持 (加载更多时)
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- src/components/room/message/MessageList.tsx
|
|
||||||
|
|
||||||
功能特性:
|
|
||||||
- [x] IntersectionObserver 自动加载更多
|
|
||||||
- [x] 滚动位置恢复
|
|
||||||
- [x] 滚动到底部按钮
|
|
||||||
- [x] 日期分组分隔符
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 9.2.2 IndexedDB 离线缓存 ✅
|
|
||||||
```
|
|
||||||
技术实现:
|
|
||||||
- IndexedDB 本地持久化存储
|
|
||||||
- 双索引: by_room, by_room_seq
|
|
||||||
- 支持离线消息恢复
|
|
||||||
- 自动保存/加载消息
|
|
||||||
|
|
||||||
API:
|
|
||||||
- saveMessage(msg) # 保存单条
|
|
||||||
- saveMessages(roomId, msgs) # 批量保存
|
|
||||||
- loadMessages(roomId) # 加载房间消息
|
|
||||||
- loadOlderMessagesFromIdb() # 加载历史消息
|
|
||||||
- getMaxSeq(roomId) # 获取最大序列号 (去重)
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- src/lib/storage/indexed-db.ts
|
|
||||||
- src/contexts/room-context.tsx (集成缓存)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.3 全文搜索 ✅
|
|
||||||
|
|
||||||
#### 9.3.1 PostgreSQL 全文索引
|
|
||||||
```
|
|
||||||
技术实现:
|
|
||||||
- content_tsv TSVECTOR 列
|
|
||||||
- GIN 索引 (idx_room_message_content_tsv)
|
|
||||||
- plainto_tsquery('simple', query) 全文搜索
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- libs/room/src/search.rs (room_message_search)
|
|
||||||
- libs/migrate/sql/m20250628_000080_add_message_reactions_and_search.sql
|
|
||||||
|
|
||||||
搜索功能:
|
|
||||||
- [x] 全文搜索 API
|
|
||||||
- [x] 分页支持 (limit, offset)
|
|
||||||
- [x] 结果计数 (total)
|
|
||||||
- [x] 显示名称解析
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.4 通知系统完善 ✅
|
|
||||||
|
|
||||||
#### 9.4.1 多维度通知配置 ✅
|
|
||||||
```
|
|
||||||
技术实现:
|
|
||||||
- room_member 表新增字段:
|
|
||||||
- do_not_disturb: BOOLEAN (免打扰开关)
|
|
||||||
- dnd_start_hour: INT (DND 开始时间, 0-23)
|
|
||||||
- dnd_end_hour: INT (DND 结束时间, 0-23)
|
|
||||||
|
|
||||||
数据库迁移:
|
|
||||||
- libs/migrate/m20250628_000078_add_room_member_do_not_disturb.rs
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- libs/models/rooms/room_member.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 9.4.2 通知系统
|
|
||||||
```
|
|
||||||
已实现功能:
|
|
||||||
- [x] 提及通知 (Mention)
|
|
||||||
- [x] 线程通知 (Thread)
|
|
||||||
- [x] DND 免打扰时段
|
|
||||||
- [x] 通知列表 API
|
|
||||||
- [x] 标记已读 API
|
|
||||||
|
|
||||||
代码位置:
|
|
||||||
- libs/room/src/notification.rs
|
|
||||||
- libs/service/user/notification.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 待实现功能
|
|
||||||
|
|
||||||
### 10.1 富媒体消息完善
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] 对象存储集成 (S3/MinIO)
|
|
||||||
2. [ ] 文件下载 API
|
|
||||||
3. [ ] 图片预览 Modal
|
|
||||||
4. [ ] 视频播放器集成
|
|
||||||
5. [ ] Office 文档预览
|
|
||||||
6. [ ] 文件大小/类型验证
|
|
||||||
7. [ ] 图片压缩 (WebWorker)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.2 全文搜索增强
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] 时间范围筛选
|
|
||||||
2. [ ] 用户筛选 (@username)
|
|
||||||
3. [ ] 文件类型筛选 (content_type)
|
|
||||||
4. [ ] 搜索历史记录
|
|
||||||
5. [ ] 结果高亮
|
|
||||||
6. [ ] 正则搜索支持
|
|
||||||
7. [ ] 数据库触发器自动更新 tsvector
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.3 推送通知
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] Web Push 集成 (service worker)
|
|
||||||
2. [ ] 移动端推送
|
|
||||||
3. [ ] 通知中心 UI
|
|
||||||
4. [ ] 未读计数 Badge
|
|
||||||
5. [ ] 关键词提醒
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.4 性能优化
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] room_message 表分区 (按时间)
|
|
||||||
2. [ ] 读写分离
|
|
||||||
3. [ ] 房间列表缓存 (Redis)
|
|
||||||
4. [ ] 成员列表缓存
|
|
||||||
5. [ ] Redis Pipeline 批量操作
|
|
||||||
6. [ ] 组件代码分割 (React.lazy)
|
|
||||||
7. [ ] 图片懒加载
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.5 AI 增强功能
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] AI 连续对话上下文管理
|
|
||||||
2. [ ] AI 会话历史管理
|
|
||||||
3. [ ] AI 切换对话线程
|
|
||||||
4. [ ] AI 输出 Markdown 渲染优化
|
|
||||||
5. [ ] AI 工具调用扩展 (消息引用/代码执行/搜索)
|
|
||||||
6. [ ] 定时 AI 任务
|
|
||||||
7. [ ] 会议纪要生成
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.6 国际化 (i18n)
|
|
||||||
|
|
||||||
```
|
|
||||||
待实现:
|
|
||||||
1. [ ] 前端 i18n (react-i18next)
|
|
||||||
2. [ ] 后端 i18n (rust-i18n)
|
|
||||||
3. [ ] 提取 UI 字符串
|
|
||||||
4. [ ] 语言切换器
|
|
||||||
5. [ ] 日期/时间本地化
|
|
||||||
6. [ ] RTL 语言支持
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 测试计划
|
|
||||||
|
|
||||||
### 11.1 单元测试
|
|
||||||
- [ ] RoomService 业务逻辑测试
|
|
||||||
- [ ] 消息序列号生成测试
|
|
||||||
- [ ] 权限检查测试
|
|
||||||
- [ ] React Hooks 测试
|
|
||||||
|
|
||||||
### 11.2 集成测试
|
|
||||||
- [ ] WebSocket 连接测试
|
|
||||||
- [ ] 数据库事务测试
|
|
||||||
- [ ] Redis 缓存测试
|
|
||||||
- [ ] NATS 消息分发测试
|
|
||||||
|
|
||||||
### 11.3 E2E 测试
|
|
||||||
- [ ] 房间创建流程
|
|
||||||
- [ ] 消息发送与接收
|
|
||||||
- [ ] 消息编辑与撤回
|
|
||||||
- [ ] AI 对话流程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 部署与运维
|
|
||||||
|
|
||||||
### 11.1 环境变量
|
|
||||||
```
|
|
||||||
# 数据库
|
|
||||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://host:6379
|
|
||||||
|
|
||||||
# NATS
|
|
||||||
NATS_URL=nats://host:4222
|
|
||||||
|
|
||||||
# 对象存储
|
|
||||||
S3_ENDPOINT=https://s3.example.com
|
|
||||||
S3_BUCKET=room-media
|
|
||||||
AWS_ACCESS_KEY_ID=xxx
|
|
||||||
AWS_SECRET_ACCESS_KEY=xxx
|
|
||||||
|
|
||||||
# AI
|
|
||||||
OPENAI_API_KEY=sk-xxx
|
|
||||||
OPENROUTER_API_KEY=xxx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11.2 Kubernetes 配置
|
|
||||||
```
|
|
||||||
# Room Service Deployment
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "1Gi"
|
|
||||||
cpu: "500m"
|
|
||||||
|
|
||||||
# HPA 自动扩缩容
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 70
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11.3 监控指标
|
|
||||||
- WebSocket 连接数
|
|
||||||
- 消息吞吐量 (msg/s)
|
|
||||||
- AI 调用延迟
|
|
||||||
- Redis 缓存命中率
|
|
||||||
- 数据库查询延迟
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 安全考虑
|
|
||||||
|
|
||||||
### 12.1 权限模型
|
|
||||||
```
|
|
||||||
项目权限 → 房间权限 → 成员角色
|
|
||||||
Admin/Owner → Admin/Owner/Member
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12.2 输入验证
|
|
||||||
- 消息内容长度限制 (MAX: 10000 chars)
|
|
||||||
- 文件大小限制 (MAX: 100MB)
|
|
||||||
- 文件类型白名单
|
|
||||||
|
|
||||||
### 12.3 CSRF/XSS 防护
|
|
||||||
- WebSocket 请求携带 JWT
|
|
||||||
- 消息内容转义
|
|
||||||
- 文件名 sanitize
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing } from 'lucide-react';
|
import { Bell, Loader2, Mail, Moon, Package, Shield } from 'lucide-react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -9,20 +9,11 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { getNotificationPreferences, updateNotificationPreferences } from '@/client';
|
import { getNotificationPreferences, updateNotificationPreferences } from '@/client';
|
||||||
import { getApiErrorMessage } from '@/lib/api-error';
|
import {getApiErrorMessage} from '@/lib/api-error';
|
||||||
import { usePushNotification } from '@/hooks/usePushNotification';
|
|
||||||
|
|
||||||
export function SettingsPreferences() {
|
export function SettingsPreferences() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isInitialized = useRef(false);
|
const isInitialized = useRef(false);
|
||||||
const { permission: pushPermission, isSubscribed: isPushSubscribed, isLoading: isPushLoading, error: pushError, subscribe: subscribePush, unsubscribe: unsubscribePush } = usePushNotification();
|
|
||||||
|
|
||||||
// Sync push_enabled state with subscription
|
|
||||||
const [pushEnabled, setPushEnabled] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPushEnabled(isPushSubscribed);
|
|
||||||
}, [isPushSubscribed]);
|
|
||||||
|
|
||||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||||
const [inAppEnabled, setInAppEnabled] = useState(true);
|
const [inAppEnabled, setInAppEnabled] = useState(true);
|
||||||
@ -89,7 +80,6 @@ export function SettingsPreferences() {
|
|||||||
marketing_enabled: marketingEnabled,
|
marketing_enabled: marketingEnabled,
|
||||||
security_enabled: securityEnabled,
|
security_enabled: securityEnabled,
|
||||||
product_enabled: productEnabled,
|
product_enabled: productEnabled,
|
||||||
push_enabled: pushEnabled,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -156,46 +146,6 @@ export function SettingsPreferences() {
|
|||||||
</div>
|
</div>
|
||||||
<Switch id="in-app-enabled" checked={inAppEnabled} onCheckedChange={setInAppEnabled} />
|
<Switch id="in-app-enabled" checked={inAppEnabled} onCheckedChange={setInAppEnabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Browser Push Notifications */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BellRing className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Label htmlFor="push-enabled" className="cursor-pointer">
|
|
||||||
Browser Push Notifications
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{pushPermission === 'unsupported'
|
|
||||||
? 'Your browser does not support push notifications'
|
|
||||||
: pushPermission === 'denied'
|
|
||||||
? 'Blocked by browser. Enable in site settings.'
|
|
||||||
: isPushSubscribed
|
|
||||||
? 'Subscribed — you will receive browser notifications'
|
|
||||||
: 'Receive notifications even when the tab is closed'}
|
|
||||||
</p>
|
|
||||||
{pushError && <p className="text-sm text-destructive">{pushError}</p>}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
id="push-enabled"
|
|
||||||
size="sm"
|
|
||||||
variant={isPushSubscribed ? 'destructive' : 'default'}
|
|
||||||
disabled={isPushLoading || pushPermission === 'unsupported' || pushPermission === 'denied'}
|
|
||||||
onClick={isPushSubscribed ? unsubscribePush : subscribePush}
|
|
||||||
>
|
|
||||||
{isPushLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
|
||||||
) : null}
|
|
||||||
{isPushLoading
|
|
||||||
? 'Loading...'
|
|
||||||
: isPushSubscribed
|
|
||||||
? 'Disable'
|
|
||||||
: 'Enable'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +1,26 @@
|
|||||||
/**
|
import type { ReactNode } from 'react';
|
||||||
* AI model icon using @lobehub/icons.
|
|
||||||
* Pass the AI model display name (e.g. "anthropic/claude-3-5-sonnet-20241022")
|
|
||||||
* as the `model` prop — lobehub's keyword regex matching handles the rest.
|
|
||||||
*/
|
|
||||||
import { memo } from 'react';
|
|
||||||
import LobehubModelIcon from '@lobehub/icons/es/features/ModelIcon';
|
|
||||||
|
|
||||||
/** Derive a readable label from the display name for the tooltip. */
|
/** Map model IDs to colored circles (fallback for AI sender avatars) */
|
||||||
export function modelDisplayLabel(displayName?: string): string {
|
export function ModelIcon({ modelId, className }: { modelId?: string; className?: string }): ReactNode {
|
||||||
if (!displayName) return 'AI';
|
const colors: Record<string, string> = {
|
||||||
// e.g. "anthropic/claude-3-5-sonnet-20241022" → "Claude 3 5 Sonnet 20241022"
|
claude: 'bg-orange-500',
|
||||||
const last = displayName.includes('/')
|
gpt: 'bg-green-600',
|
||||||
? displayName.split('/').pop()!
|
gemini: 'bg-blue-600',
|
||||||
: displayName;
|
deepseek: 'bg-purple-600',
|
||||||
return last
|
o1: 'bg-pink-600',
|
||||||
.replace(/[-_]/g, ' ')
|
o3: 'bg-pink-700',
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
o4: 'bg-pink-800',
|
||||||
}
|
ai: 'bg-primary',
|
||||||
|
};
|
||||||
|
|
||||||
interface ModelIconProps {
|
const color = modelId ? (colors[modelId.toLowerCase()] ?? 'bg-primary') : 'bg-primary';
|
||||||
/** AI model display name from the backend (e.g. "anthropic/claude-3-5-sonnet"). */
|
|
||||||
model?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Colored AI model logo icon from @lobehub/icons.
|
|
||||||
* Supports 250+ AI models via keyword regex matching.
|
|
||||||
* Falls back to a default circle when no match is found. */
|
|
||||||
export const ModelIcon = memo(function ModelIcon({ model, className }: ModelIconProps) {
|
|
||||||
return (
|
return (
|
||||||
<LobehubModelIcon
|
<span
|
||||||
model={model}
|
className={`inline-block h-5 w-5 rounded-full ${color} flex items-center justify-center ${className ?? ''}`}
|
||||||
type="avatar"
|
title={modelId}
|
||||||
size={20}
|
>
|
||||||
className={className}
|
<span className="text-[10px] font-bold text-white">A</span>
|
||||||
/>
|
</span>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { ModelIcon } from '../icon-match';
|
|||||||
import { FunctionCallBadge } from '../FunctionCallBadge';
|
import { FunctionCallBadge } from '../FunctionCallBadge';
|
||||||
import { MessageContent } from './MessageContent';
|
import { MessageContent } from './MessageContent';
|
||||||
import { ThreadIndicator } from '../RoomThreadPanel';
|
import { ThreadIndicator } from '../RoomThreadPanel';
|
||||||
import { getSenderDisplayName, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
|
import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
|
||||||
import { MessageReactions } from './MessageReactions';
|
import { MessageReactions } from './MessageReactions';
|
||||||
import { ReactionPicker } from './ReactionPicker';
|
import { ReactionPicker } from './ReactionPicker';
|
||||||
|
|
||||||
@ -78,6 +78,7 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
|
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
|
||||||
const isSystem = message.sender_type === 'system';
|
const isSystem = message.sender_type === 'system';
|
||||||
const displayName = getSenderDisplayName(message);
|
const displayName = getSenderDisplayName(message);
|
||||||
|
const senderModelId = getSenderModelId(message);
|
||||||
const avatarUrl = getAvatarFromUiMessage(message);
|
const avatarUrl = getAvatarFromUiMessage(message);
|
||||||
const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
|
const initial = (displayName?.charAt(0) ?? '?').toUpperCase();
|
||||||
const isStreaming = !!message.is_streaming;
|
const isStreaming = !!message.is_streaming;
|
||||||
@ -208,7 +209,7 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
className="text-sm font-semibold"
|
className="text-sm font-semibold"
|
||||||
style={{ background: `${senderColor}22`, color: senderColor }}
|
style={{ background: `${senderColor}22`, color: senderColor }}
|
||||||
>
|
>
|
||||||
{isAi ? <ModelIcon model={displayName} /> : initial}
|
{isAi ? <ModelIcon modelId={senderModelId} /> : initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</button>
|
</button>
|
||||||
@ -317,10 +318,12 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<MessageContent
|
<div className="whitespace-pre-wrap break-words">
|
||||||
|
<MessageContent
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
onMentionClick={handleMentionClick}
|
onMentionClick={handleMentionClick}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Streaming cursor */}
|
{/* Streaming cursor */}
|
||||||
|
|||||||
@ -1,14 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders message content — markdown with @[type:id:label] mentions.
|
* Renders message content — parses @[type:id:label] mentions into styled spans.
|
||||||
* Mentions are protected from markdown parsing by replacing them with
|
|
||||||
* placeholder tokens before rendering, then restored in custom text components.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useMemo } from 'react';
|
|
||||||
import Markdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface MessageContentProps {
|
interface MessageContentProps {
|
||||||
@ -16,23 +11,35 @@ interface MessageContentProps {
|
|||||||
onMentionClick?: (type: string, id: string, label: string) => void;
|
onMentionClick?: (type: string, id: string, label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
/** Parses @[type:id:label] patterns from message content */
|
||||||
|
function parseContent(content: string): Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> {
|
||||||
|
const parts: Array<{ type: 'text' | 'mention'; text?: string; mention?: { type: string; id: string; label: string } }> = [];
|
||||||
|
const RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
interface MentionInfo {
|
while ((match = RE.exec(content)) !== null) {
|
||||||
type: string;
|
// Text before this match
|
||||||
id: string;
|
if (match.index > lastIndex) {
|
||||||
label: string;
|
parts.push({ type: 'text', text: content.slice(lastIndex, match.index) });
|
||||||
}
|
}
|
||||||
|
parts.push({
|
||||||
|
type: 'mention',
|
||||||
|
mention: {
|
||||||
|
type: match[1],
|
||||||
|
id: match[2],
|
||||||
|
label: match[3],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
lastIndex = RE.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
|
// Remaining text
|
||||||
function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
|
if (lastIndex < content.length) {
|
||||||
const mentions: MentionInfo[] = [];
|
parts.push({ type: 'text', text: content.slice(lastIndex) });
|
||||||
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
|
}
|
||||||
const idx = mentions.length;
|
|
||||||
mentions.push({ type, id, label });
|
return parts;
|
||||||
return `\u200BMENTION_${idx}\u200B`; // zero-width spaces prevent markdown parsing
|
|
||||||
});
|
|
||||||
return { safeContent, mentions };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMentionStyle(type: string): string {
|
function getMentionStyle(type: string): string {
|
||||||
@ -45,149 +52,42 @@ function getMentionStyle(type: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore mention placeholders inside a text node into React elements */
|
export function MessageContent({ content, onMentionClick }: MessageContentProps) {
|
||||||
function restoreMentions(text: string, mentions: MentionInfo[], onMentionClick?: (type: string, id: string, label: string) => void): React.ReactNode[] {
|
const parts = parseContent(content);
|
||||||
const MENTION_PLACEHOLDER_RE = /\u200BMENTION_(\d+)\u200B/g;
|
|
||||||
const parts: React.ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = MENTION_PLACEHOLDER_RE.exec(text)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
const idx = parseInt(match[1], 10);
|
|
||||||
const m = mentions[idx];
|
|
||||||
if (m) {
|
|
||||||
parts.push(
|
|
||||||
<span
|
|
||||||
key={`mention-${idx}`}
|
|
||||||
role={onMentionClick ? 'button' : undefined}
|
|
||||||
tabIndex={onMentionClick ? 0 : undefined}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
|
||||||
getMentionStyle(m.type),
|
|
||||||
)}
|
|
||||||
onClick={() => onMentionClick?.(m.type, m.id, m.label)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
|
||||||
e.preventDefault();
|
|
||||||
onMentionClick(m.type, m.id, m.label);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
@{m.label}
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lastIndex = MENTION_PLACEHOLDER_RE.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(text.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageContent = memo(function MessageContent({ content, onMentionClick }: MessageContentProps) {
|
|
||||||
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[15px] text-foreground',
|
'text-sm text-foreground',
|
||||||
'max-w-full min-w-0 break-words',
|
'max-w-full min-w-0 break-words whitespace-pre-wrap',
|
||||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
||||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
||||||
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
|
|
||||||
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
|
|
||||||
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
|
|
||||||
'[&_li]:my-0.5',
|
|
||||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
|
|
||||||
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
|
|
||||||
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
|
|
||||||
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
|
|
||||||
'[&_strong]:font-semibold',
|
|
||||||
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
|
|
||||||
'[&_hr]:border-foreground/20 [&_hr]:my-2',
|
|
||||||
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
|
|
||||||
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
|
|
||||||
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
|
|
||||||
'[&_tr]:border-t [&_tr]:even:bg-muted',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Markdown
|
{parts.map((part, i) =>
|
||||||
remarkPlugins={[remarkGfm]}
|
part.type === 'text' ? (
|
||||||
components={{
|
<span key={i}>{part.text}</span>
|
||||||
p: ({ children }) => {
|
) : (
|
||||||
// Restore mentions in paragraph text nodes
|
<span
|
||||||
if (typeof children === 'string') {
|
key={i}
|
||||||
return <p>{restoreMentions(children, mentions, onMentionClick)}</p>;
|
role={onMentionClick ? 'button' : undefined}
|
||||||
}
|
tabIndex={onMentionClick ? 0 : undefined}
|
||||||
// Children may be an array of strings/elements
|
className={cn(
|
||||||
if (Array.isArray(children)) {
|
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
||||||
const restored = children.map((child) => {
|
getMentionStyle(part.mention!.type),
|
||||||
if (typeof child === 'string') {
|
)}
|
||||||
return restoreMentions(child, mentions, onMentionClick);
|
onClick={() => onMentionClick?.(part.mention!.type, part.mention!.id, part.mention!.label)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
onMentionClick(part.mention!.type, part.mention!.id, part.mention!.label);
|
||||||
}
|
}
|
||||||
return child;
|
}}
|
||||||
});
|
>
|
||||||
return <p>{restored}</p>;
|
<span>@{part.mention!.label}</span>
|
||||||
}
|
</span>
|
||||||
return <p>{children}</p>;
|
),
|
||||||
},
|
)}
|
||||||
li: ({ children }) => {
|
|
||||||
if (typeof children === 'string') {
|
|
||||||
return <li>{restoreMentions(children, mentions, onMentionClick)}</li>;
|
|
||||||
}
|
|
||||||
if (Array.isArray(children)) {
|
|
||||||
const restored = children.map((child) => {
|
|
||||||
if (typeof child === 'string') {
|
|
||||||
return restoreMentions(child, mentions, onMentionClick);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
return <li>{restored}</li>;
|
|
||||||
}
|
|
||||||
return <li>{children}</li>;
|
|
||||||
},
|
|
||||||
strong: ({ children }) => {
|
|
||||||
if (typeof children === 'string') {
|
|
||||||
return <strong>{restoreMentions(children, mentions, onMentionClick)}</strong>;
|
|
||||||
}
|
|
||||||
return <strong>{children}</strong>;
|
|
||||||
},
|
|
||||||
em: ({ children }) => {
|
|
||||||
if (typeof children === 'string') {
|
|
||||||
return <em>{restoreMentions(children, mentions, onMentionClick)}</em>;
|
|
||||||
}
|
|
||||||
return <em>{children}</em>;
|
|
||||||
},
|
|
||||||
code: ({ className, children, ...props }) => {
|
|
||||||
// Inline code — don't restore mentions inside code blocks
|
|
||||||
const isBlock = typeof className === 'string' && className.includes('language-');
|
|
||||||
if (isBlock) {
|
|
||||||
// Fenced code block — let the pre wrapper handle it
|
|
||||||
return <code className={className} {...props}>{children}</code>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code
|
|
||||||
className="font-mono rounded bg-muted px-1 py-0.5 text-xs"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pre: ({ children }) => {
|
|
||||||
// Preserve code blocks as-is, no mention restoration
|
|
||||||
return <pre className="rounded-md bg-muted p-3 overflow-x-auto">{children}</pre>;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{safeContent}
|
|
||||||
</Markdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { Paperclip, Smile, Send, X } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { COMMON_EMOJIS } from '../../shared';
|
import { COMMON_EMOJIS } from '../../shared';
|
||||||
import { useTheme } from '@/contexts';
|
import { useTheme } from '@/contexts';
|
||||||
import { useImageCompress } from '@/hooks/useImageCompress';
|
|
||||||
|
|
||||||
export interface IMEditorProps {
|
export interface IMEditorProps {
|
||||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||||
@ -244,7 +243,6 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
) {
|
) {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
|
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
|
||||||
const { compress } = useImageCompress();
|
|
||||||
|
|
||||||
const [showEmoji, setShowEmoji] = useState(false);
|
const [showEmoji, setShowEmoji] = useState(false);
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
@ -340,14 +338,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const doUpload = async (file: File) => {
|
const doUpload = async (file: File) => {
|
||||||
if (!editor || !onUploadFile) return;
|
if (!editor || !onUploadFile) return;
|
||||||
try {
|
try {
|
||||||
// Compress image before upload (only if it's an image and > 500KB)
|
const res = await onUploadFile(file);
|
||||||
let uploadFile = file;
|
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: file.name, url: res.url, size: file.size, type: file.type, status: 'done' } }).insertContent(' ').run();
|
||||||
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
|
|
||||||
const result = await compress(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true });
|
|
||||||
uploadFile = result.file;
|
|
||||||
}
|
|
||||||
const res = await onUploadFile(uploadFile);
|
|
||||||
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -178,9 +178,6 @@ export function RoomProvider({
|
|||||||
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
|
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
|
||||||
const [wsError, setWsError] = useState<string | null>(null);
|
const [wsError, setWsError] = useState<string | null>(null);
|
||||||
const [wsToken, setWsToken] = useState<string | null>(null);
|
const [wsToken, setWsToken] = useState<string | null>(null);
|
||||||
// Buffer for messages received while user is in a different room (Bug 3 fix).
|
|
||||||
// Merged into state when the user switches to that room.
|
|
||||||
const pendingRoomMessagesRef = useRef<Map<string, RoomMessagePayload[]>>(new Map());
|
|
||||||
|
|
||||||
// Keep ref updated with latest activeRoomId
|
// Keep ref updated with latest activeRoomId
|
||||||
activeRoomIdRef.current = activeRoomId;
|
activeRoomIdRef.current = activeRoomId;
|
||||||
@ -240,19 +237,6 @@ export function RoomProvider({
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setIsHistoryLoaded(false);
|
setIsHistoryLoaded(false);
|
||||||
setNextCursor(null);
|
setNextCursor(null);
|
||||||
|
|
||||||
// Merge any buffered messages for the new room (Bug 3 fix)
|
|
||||||
if (activeRoomId) {
|
|
||||||
const pending = pendingRoomMessagesRef.current.get(activeRoomId);
|
|
||||||
if (pending && pending.length > 0) {
|
|
||||||
pendingRoomMessagesRef.current.delete(activeRoomId);
|
|
||||||
setMessages((prev) => {
|
|
||||||
const merged = [...prev, ...pending.map(wsMessageToUiMessage)];
|
|
||||||
merged.sort((a, b) => a.seq - b.seq);
|
|
||||||
return merged;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
|
// NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
|
||||||
// load when the user returns to this room without waiting for API.
|
// load when the user returns to this room without waiting for API.
|
||||||
}
|
}
|
||||||
@ -412,16 +396,6 @@ export function RoomProvider({
|
|||||||
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
|
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
|
||||||
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
||||||
|
|
||||||
// ── Update WS token on existing client (instead of recreating client) ────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (wsToken && wsClientRef.current) {
|
|
||||||
wsClientRef.current.setWsToken(wsToken);
|
|
||||||
}
|
|
||||||
}, [wsToken]);
|
|
||||||
|
|
||||||
// ── Create WS client ONCE on mount ──────────────────────────────────────────
|
|
||||||
// Recreating the client on wsToken change caused multiple invalid connections
|
|
||||||
// (Bug 1). Instead, create once and update the token in-place.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
||||||
const client = createRoomWsClient(
|
const client = createRoomWsClient(
|
||||||
@ -433,22 +407,13 @@ export function RoomProvider({
|
|||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existingIdx = prev.findIndex((m) => m.id === payload.id);
|
const existingIdx = prev.findIndex((m) => m.id === payload.id);
|
||||||
if (existingIdx !== -1) {
|
if (existingIdx !== -1) {
|
||||||
// Message already exists (e.g. created by streaming chunk) —
|
// Message already exists — update reactions if provided
|
||||||
// merge server-side fields (display_name, reactions) that the
|
if (payload.reactions !== undefined) {
|
||||||
// chunk didn't have.
|
|
||||||
const existing = prev[existingIdx];
|
|
||||||
const needsUpdate =
|
|
||||||
(!existing.display_name && payload.display_name) ||
|
|
||||||
(payload.reactions !== undefined && existing.reactions === undefined);
|
|
||||||
if (needsUpdate) {
|
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
updated[existingIdx] = {
|
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
|
||||||
...existing,
|
|
||||||
display_name: payload.display_name ?? existing.display_name,
|
|
||||||
reactions: payload.reactions ?? existing.reactions,
|
|
||||||
};
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
// Duplicate of a real message — ignore
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
// Replace optimistic message with server-confirmed one
|
// Replace optimistic message with server-confirmed one
|
||||||
@ -472,16 +437,9 @@ export function RoomProvider({
|
|||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// Buffer messages for non-active rooms (Bug 3 fix).
|
|
||||||
// When user switches to that room, pending messages are merged.
|
|
||||||
pendingRoomMessagesRef.current.set(payload.room_id, [
|
|
||||||
...pendingRoomMessagesRef.current.get(payload.room_id) ?? [],
|
|
||||||
payload,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
|
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string }) => {
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
setStreamingContent((prev) => {
|
setStreamingContent((prev) => {
|
||||||
prev.delete(chunk.message_id);
|
prev.delete(chunk.message_id);
|
||||||
@ -522,7 +480,6 @@ export function RoomProvider({
|
|||||||
room: chunk.room_id,
|
room: chunk.room_id,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
sender_type: 'ai',
|
sender_type: 'ai',
|
||||||
display_name: chunk.display_name,
|
|
||||||
content: accumulated,
|
content: accumulated,
|
||||||
display_content: accumulated,
|
display_content: accumulated,
|
||||||
content_type: 'text',
|
content_type: 'text',
|
||||||
@ -536,7 +493,7 @@ export function RoomProvider({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
if (!activeRoomIdRef.current) return;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
|
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
|
||||||
if (existingIdx === -1) return prev;
|
if (existingIdx === -1) return prev;
|
||||||
@ -632,21 +589,26 @@ export function RoomProvider({
|
|||||||
setWsError(error.message);
|
setWsError(error.message);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ wsToken: wsToken ?? undefined },
|
||||||
);
|
);
|
||||||
|
|
||||||
setWsClient(client);
|
setWsClient(client);
|
||||||
wsClientRef.current = client;
|
wsClientRef.current = client;
|
||||||
|
|
||||||
// Connect immediately — connect() fetches its own token if needed
|
|
||||||
client.connect().catch((e) => {
|
|
||||||
console.error('[RoomContext] WS connect error:', e);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.disconnect(); // Intentional disconnect on unmount — no reconnect
|
client.disconnect();
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
};
|
};
|
||||||
}, []); // ← empty deps: create once on mount
|
}, [wsToken]);
|
||||||
|
|
||||||
|
// ── Connect WS whenever a new client is created ─────────────────────────────
|
||||||
|
// Intentionally depends on wsClient (not wsClientRef) so a new client triggers connect().
|
||||||
|
// connect() is idempotent — no-op if already connecting/open.
|
||||||
|
useEffect(() => {
|
||||||
|
wsClientRef.current?.connect().catch((e) => {
|
||||||
|
console.error('[RoomContext] WS connect error:', e);
|
||||||
|
});
|
||||||
|
}, [wsClient]);
|
||||||
|
|
||||||
const connectWs = useCallback(async () => {
|
const connectWs = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
const client = wsClientRef.current;
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import imageCompression from 'browser-image-compression';
|
|
||||||
|
|
||||||
export interface ImageCompressOptions {
|
|
||||||
maxSizeMB?: number;
|
|
||||||
maxWidthOrHeight?: number;
|
|
||||||
useWebWorker?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompressionResult {
|
|
||||||
file: File;
|
|
||||||
originalSize: number;
|
|
||||||
compressedSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compresses an image file using browser-image-compression.
|
|
||||||
* Runs in a WebWorker by default to avoid blocking the main thread.
|
|
||||||
*/
|
|
||||||
export function useImageCompress() {
|
|
||||||
const [isCompressing, setIsCompressing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const compress = useCallback(async (
|
|
||||||
file: File,
|
|
||||||
options: ImageCompressOptions = {}
|
|
||||||
): Promise<CompressionResult> => {
|
|
||||||
setIsCompressing(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
maxSizeMB: 1,
|
|
||||||
maxWidthOrHeight: 1920,
|
|
||||||
useWebWorker: true,
|
|
||||||
fileType: file.type,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const compressed = await imageCompression(file, defaultOptions);
|
|
||||||
setIsCompressing(false);
|
|
||||||
return {
|
|
||||||
file: compressed as File,
|
|
||||||
originalSize: file.size,
|
|
||||||
compressedSize: compressed.size,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Compression failed';
|
|
||||||
setError(msg);
|
|
||||||
setIsCompressing(false);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { compress, isCompressing, error };
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
export type PushPermissionState = NotificationPermission | 'unsupported';
|
|
||||||
|
|
||||||
export interface PushSubscriptionInfo {
|
|
||||||
endpoint: string;
|
|
||||||
p256dh: string;
|
|
||||||
auth: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsePushNotificationReturn {
|
|
||||||
permission: PushPermissionState;
|
|
||||||
isSubscribed: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
subscribe: () => Promise<void>;
|
|
||||||
unsubscribe: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing Web Push notification subscriptions.
|
|
||||||
* Handles Service Worker registration, push permission, and subscription lifecycle.
|
|
||||||
*/
|
|
||||||
export function usePushNotification(): UsePushNotificationReturn {
|
|
||||||
const [permission, setPermission] = useState<PushPermissionState>('unsupported');
|
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Check initial state on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (!('Notification' in self) || !('serviceWorker' in navigator) || !('PushManager' in self)) {
|
|
||||||
setPermission('unsupported');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPermission(Notification.permission);
|
|
||||||
|
|
||||||
// Check if already subscribed
|
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
|
||||||
registration.pushManager.getSubscription().then((sub) => {
|
|
||||||
setIsSubscribed(!!sub);
|
|
||||||
}).catch(() => {
|
|
||||||
setIsSubscribed(false);
|
|
||||||
});
|
|
||||||
}).catch(() => {
|
|
||||||
setIsSubscribed(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const subscribe = useCallback(async () => {
|
|
||||||
if (permission === 'unsupported') {
|
|
||||||
setError('Push notifications are not supported in this browser.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (permission === 'denied') {
|
|
||||||
setError('Push notifications are blocked. Please enable them in your browser settings.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Register / get Service Worker
|
|
||||||
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
|
||||||
// If already registered, just get it
|
|
||||||
return navigator.serviceWorker.ready;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Request permission if not granted
|
|
||||||
if (Notification.permission !== 'granted') {
|
|
||||||
const result = await Notification.requestPermission();
|
|
||||||
if (result !== 'granted') {
|
|
||||||
setPermission(result);
|
|
||||||
setError('Permission denied. Cannot subscribe to push notifications.');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPermission('granted');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get VAPID public key from server
|
|
||||||
const vapidResponse = await axios.get<{ data?: { public_key?: string } }>(
|
|
||||||
'/api/users/me/notifications/push/vapid-key'
|
|
||||||
);
|
|
||||||
const publicKey = vapidResponse.data?.data?.public_key;
|
|
||||||
if (!publicKey) {
|
|
||||||
throw new Error('VAPID public key not available from server.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Subscribe to push
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey) as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Extract subscription details
|
|
||||||
const raw = subscription.toJSON();
|
|
||||||
const pushSubscription: PushSubscriptionInfo = {
|
|
||||||
endpoint: raw.endpoint ?? '',
|
|
||||||
p256dh: raw.keys?.p256dh ?? '',
|
|
||||||
auth: raw.keys?.auth ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Save subscription to server via the preferences endpoint
|
|
||||||
// The server stores these in user_notification.push_subscription_* columns
|
|
||||||
await axios.patch('/api/users/me/notifications/preferences', {
|
|
||||||
push_subscription_endpoint: pushSubscription.endpoint,
|
|
||||||
push_subscription_keys_p256dh: pushSubscription.p256dh,
|
|
||||||
push_subscription_keys_auth: pushSubscription.auth,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSubscribed(true);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to subscribe to push notifications';
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [permission]);
|
|
||||||
|
|
||||||
const unsubscribe = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Unsubscribe from push manager
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
const subscription = await registration.pushManager.getSubscription();
|
|
||||||
if (subscription) {
|
|
||||||
await subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Clear subscription on server
|
|
||||||
await axios.delete('/api/users/me/notifications/push/subscription');
|
|
||||||
|
|
||||||
setIsSubscribed(false);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to unsubscribe from push notifications';
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { permission, isSubscribed, isLoading, error, subscribe, unsubscribe };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Utility ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Base64url string to a Uint8Array (for applicationServerKey).
|
|
||||||
* Matches the browser's built-in urlBase64ToUint8Array behavior.
|
|
||||||
*/
|
|
||||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
|
|
||||||
const rawData = atob(base64);
|
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return outputArray;
|
|
||||||
}
|
|
||||||
@ -249,12 +249,8 @@ export class RoomWsClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(graceful = false): void {
|
disconnect(): void {
|
||||||
// Only permanently disable reconnect on intentional disconnect (user action).
|
this.shouldReconnect = false;
|
||||||
// Graceful disconnect (cleanup from effect swap) allows reconnect to continue.
|
|
||||||
if (!graceful) {
|
|
||||||
this.shouldReconnect = false;
|
|
||||||
}
|
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
@ -1015,10 +1011,7 @@ export class RoomWsClient {
|
|||||||
break;
|
break;
|
||||||
case 'room.reaction_updated':
|
case 'room.reaction_updated':
|
||||||
case 'room_reaction_updated':
|
case 'room_reaction_updated':
|
||||||
this.callbacks.onRoomReactionUpdated?.({
|
this.callbacks.onRoomReactionUpdated?.(event.data as RoomReactionUpdatedPayload);
|
||||||
...(event.data as Omit<RoomReactionUpdatedPayload, 'room_id'>),
|
|
||||||
room_id: event.room_id ?? '',
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown event type - ignore silently
|
// Unknown event type - ignore silently
|
||||||
@ -1070,12 +1063,9 @@ export class RoomWsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resubscribeAll(): Promise<void> {
|
private async resubscribeAll(): Promise<void> {
|
||||||
// Subscribe/unsubscribe are WS-only actions — request() would fall back to HTTP
|
|
||||||
// which maps them to POST /ws (not a real REST endpoint), causing 404 failures.
|
|
||||||
// Use requestWs() to ensure they go through the WebSocket.
|
|
||||||
for (const roomId of this.subscribedRooms) {
|
for (const roomId of this.subscribedRooms) {
|
||||||
try {
|
try {
|
||||||
await this.requestWs<SubscribeData>('room.subscribe', { room_id: roomId });
|
await this.request('room.subscribe', { room_id: roomId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Resubscribe failure is non-fatal — messages still arrive via REST poll.
|
// Resubscribe failure is non-fatal — messages still arrive via REST poll.
|
||||||
// Log at warn level so operators can observe patterns (e.g. auth expiry).
|
// Log at warn level so operators can observe patterns (e.g. auth expiry).
|
||||||
@ -1084,7 +1074,7 @@ export class RoomWsClient {
|
|||||||
}
|
}
|
||||||
for (const projectName of this.subscribedProjects) {
|
for (const projectName of this.subscribedProjects) {
|
||||||
try {
|
try {
|
||||||
await this.requestWs<SubscribeData>('project.subscribe', { project_name: projectName });
|
await this.request('project.subscribe', { project_name: projectName });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[RoomWs] resubscribe project failed (will retry on next reconnect): ${projectName}`, err);
|
console.warn(`[RoomWs] resubscribe project failed (will retry on next reconnect): ${projectName}`, err);
|
||||||
}
|
}
|
||||||
@ -1099,9 +1089,7 @@ export class RoomWsClient {
|
|||||||
// (thundering herd) after a server restart, overwhelming it.
|
// (thundering herd) after a server restart, overwhelming it.
|
||||||
const baseDelay = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt);
|
const baseDelay = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt);
|
||||||
const cappedDelay = Math.min(baseDelay, this.reconnectMaxDelay);
|
const cappedDelay = Math.min(baseDelay, this.reconnectMaxDelay);
|
||||||
// Ensure minimum 500ms delay to avoid hitting backend 30s connection cooldown
|
const jitter = Math.random() * cappedDelay;
|
||||||
const minDelay = 500;
|
|
||||||
const jitter = minDelay + Math.random() * (cappedDelay - minDelay);
|
|
||||||
const delay = Math.floor(jitter);
|
const delay = Math.floor(jitter);
|
||||||
this.reconnectAttempt++;
|
this.reconnectAttempt++;
|
||||||
|
|
||||||
|
|||||||
@ -186,8 +186,6 @@ export interface AiStreamChunkPayload {
|
|||||||
content: string;
|
content: string;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */
|
|
||||||
display_name?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomResponse {
|
export interface RoomResponse {
|
||||||
@ -285,7 +283,6 @@ export interface ReactionListData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomReactionUpdatedPayload {
|
export interface RoomReactionUpdatedPayload {
|
||||||
room_id: string;
|
|
||||||
message_id: string;
|
message_id: string;
|
||||||
reactions: ReactionItem[];
|
reactions: ReactionItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/types/browser-image-compression.d.ts
vendored
17
src/types/browser-image-compression.d.ts
vendored
@ -1,17 +0,0 @@
|
|||||||
declare module 'browser-image-compression' {
|
|
||||||
export interface Options {
|
|
||||||
maxSizeMB?: number;
|
|
||||||
maxWidthOrHeight?: number;
|
|
||||||
useWebWorker?: boolean;
|
|
||||||
fileType?: string;
|
|
||||||
initialQuality?: number;
|
|
||||||
alwaysKeepResolution?: boolean;
|
|
||||||
preserveExif?: boolean;
|
|
||||||
onProgress?: (progress: number) => void;
|
|
||||||
usePixelLength?: boolean;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageCompression(file: File | Blob, options?: Options): Promise<Blob>;
|
|
||||||
export default imageCompression;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user