Compare commits
15 Commits
05909dbde7
...
eba75ee359
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba75ee359 | ||
|
|
779aaba575 | ||
|
|
6f6f57f062 | ||
|
|
8316fe926f | ||
|
|
0c64122b80 | ||
|
|
26865f8dcf | ||
|
|
0e4631ec75 | ||
|
|
26c86f0796 | ||
|
|
cec8d486f1 | ||
|
|
1b863a9f65 | ||
|
|
2186960002 | ||
|
|
a2e8f5bf5b | ||
|
|
98e6f77341 | ||
|
|
d09af7c326 | ||
|
|
ba15324603 |
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",
|
"derive_more 2.1.1",
|
||||||
"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",
|
"derive_more 2.1.1",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http-range",
|
"http-range",
|
||||||
"log",
|
"log",
|
||||||
@ -113,7 +113,7 @@ dependencies = [
|
|||||||
"brotli",
|
"brotli",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytestring",
|
"bytestring",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
@ -147,6 +147,44 @@ 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"
|
||||||
@ -228,7 +266,7 @@ dependencies = [
|
|||||||
"bytestring",
|
"bytestring",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cookie",
|
"cookie",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -369,6 +407,16 @@ 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"
|
||||||
@ -556,6 +604,7 @@ version = "0.2.9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
"actix-multipart",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-ws",
|
"actix-ws",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@ -566,6 +615,7 @@ dependencies = [
|
|||||||
"email",
|
"email",
|
||||||
"frontend",
|
"frontend",
|
||||||
"futures",
|
"futures",
|
||||||
|
"futures-util",
|
||||||
"git",
|
"git",
|
||||||
"mime_guess2",
|
"mime_guess2",
|
||||||
"models",
|
"models",
|
||||||
@ -672,6 +722,12 @@ 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"
|
||||||
@ -1141,6 +1197,12 @@ 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"
|
||||||
@ -1201,6 +1263,17 @@ 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"
|
||||||
@ -1538,6 +1611,17 @@ 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"
|
||||||
@ -1631,6 +1715,12 @@ 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"
|
||||||
@ -1728,9 +1818,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc-catalog",
|
"crc-catalog",
|
||||||
]
|
]
|
||||||
@ -1858,6 +1948,12 @@ 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"
|
||||||
@ -2090,6 +2186,19 @@ 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"
|
||||||
@ -2203,6 +2312,17 @@ 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"
|
||||||
@ -2213,6 +2333,16 @@ 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"
|
||||||
@ -2622,6 +2752,7 @@ name = "frontend"
|
|||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"md5",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -3267,6 +3398,30 @@ 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"
|
||||||
@ -3903,6 +4058,46 @@ 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"
|
||||||
@ -5033,6 +5228,12 @@ 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"
|
||||||
@ -6597,7 +6798,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bigdecimal",
|
"bigdecimal",
|
||||||
"chrono",
|
"chrono",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
@ -6893,6 +7094,15 @@ 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"
|
||||||
@ -6928,6 +7138,7 @@ dependencies = [
|
|||||||
"async-openai",
|
"async-openai",
|
||||||
"avatar",
|
"avatar",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"base64ct",
|
||||||
"calamine",
|
"calamine",
|
||||||
"captcha-rs",
|
"captcha-rs",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -6942,9 +7153,12 @@ 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",
|
||||||
@ -6970,6 +7184,7 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"web-push-native",
|
||||||
"zip 8.4.0",
|
"zip 8.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -6982,7 +7197,7 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"deadpool-redis",
|
"deadpool-redis",
|
||||||
"derive_more",
|
"derive_more 2.1.1",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"redis",
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
@ -7550,6 +7765,21 @@ 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"
|
||||||
@ -8342,6 +8572,15 @@ 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"
|
||||||
@ -8445,6 +8684,23 @@ 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,7 +106,8 @@ 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-sdk-s3 = "1.127.0"
|
# aws-lc-sys requires NASM on Windows, so we use local filesystem storage instead of S3
|
||||||
|
# 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"] }
|
||||||
@ -149,6 +150,7 @@ 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"
|
||||||
@ -157,6 +159,9 @@ 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"],
|
serverExternalPackages: ["bcrypt", "ioredis"],
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,16 +35,20 @@ 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: Cluster 是 default export, redisOptions 展开到顶层, 无 clusterRetryStrategy
|
// ioredis 5.x: username/password 必须放在 redisOptions 里,不能放顶层
|
||||||
// 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: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
retryStrategy: (times: number) => Math.min(times * 100, 3000),
|
retryStrategy: (times: number) => Math.min(times * 100, 3000),
|
||||||
// 从第一个 URL 提取认证信息(所有节点共用相同密码)
|
},
|
||||||
username: firstUrl.username || undefined,
|
|
||||||
password: firstUrl.password || undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return cluster as Redis;
|
return cluster as Redis;
|
||||||
@ -52,6 +56,16 @@ 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,10 +42,12 @@ 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,29 +1,69 @@
|
|||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{http::header, web, HttpRequest, HttpResponse};
|
||||||
use mime_guess2::MimeGuess;
|
use mime_guess2::MimeGuess;
|
||||||
|
|
||||||
pub async fn serve_frontend(path: web::Path<String>) -> HttpResponse {
|
fn cache_control_header(path: &str) -> &'static str {
|
||||||
|
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 = if path.is_empty() || path == "/" {
|
let path_str = if path.is_empty() || path == "/" {
|
||||||
"index.html"
|
"index.html"
|
||||||
} else {
|
} else {
|
||||||
&path
|
path.as_str()
|
||||||
};
|
};
|
||||||
|
|
||||||
match frontend::get_frontend_asset(path) {
|
let cc = cache_control_header(path_str);
|
||||||
Some(data) => {
|
|
||||||
let mime = MimeGuess::from_path(path).first_or_octet_stream();
|
match frontend::get_frontend_asset_with_etag(path_str) {
|
||||||
|
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 => {
|
None => match frontend::get_frontend_asset_with_etag("index.html") {
|
||||||
// Fallback to index.html for SPA routing
|
Some((data, etag)) => HttpResponse::Ok()
|
||||||
match frontend::get_frontend_asset("index.html") {
|
|
||||||
Some(data) => HttpResponse::Ok()
|
|
||||||
.content_type("text/html")
|
.content_type("text/html")
|
||||||
|
.insert_header(("Cache-Control", "no-cache, no-store, must-revalidate"))
|
||||||
|
.insert_header(("ETag", etag))
|
||||||
.body(data.to_vec()),
|
.body(data.to_vec()),
|
||||||
None => HttpResponse::NotFound().finish(),
|
None => HttpResponse::NotFound().finish(),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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;
|
||||||
@ -173,6 +174,11 @@ 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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
140
libs/api/room/upload.rs
Normal file
140
libs/api/room/upload.rs
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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,6 +98,8 @@ 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 {
|
||||||
@ -108,6 +110,7 @@ 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,6 +55,14 @@ 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,8 +1,57 @@
|
|||||||
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,7 +22,8 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
env = env.into_iter().chain(std::env::vars()).collect();
|
// Environment variables (e.g. K8s injected APP_DOMAIN_URL) take precedence over .env files
|
||||||
|
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);
|
||||||
@ -47,3 +48,4 @@ pub mod qdrant;
|
|||||||
pub mod redis;
|
pub mod redis;
|
||||||
pub mod smtp;
|
pub mod smtp;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
|
pub mod storage;
|
||||||
|
|||||||
39
libs/config/storage.rs
Normal file
39
libs/config/storage.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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,3 +8,4 @@ lazy_static.workspace = true
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
|
md5.workspace = true
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
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) {
|
||||||
@ -66,15 +67,24 @@ 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!(" ({}, include_bytes!(\"dist_blobs/{}\")),", key_literal, safe_name));
|
strs.push(format!(
|
||||||
|
" ({}, 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 content = format!(
|
let generated = format!(
|
||||||
"lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8])> = vec![\n{} ];\n}}\n",
|
"lazy_static::lazy_static! {{\n pub static ref FRONTEND: Vec<(&'static str, &'static [u8], &'static str)> = vec![\n{} ];\n}}\n",
|
||||||
strs.join("\n")
|
strs.join("\n")
|
||||||
);
|
);
|
||||||
fs::write(&out_file, content).unwrap();
|
fs::write(&out_file, generated).unwrap();
|
||||||
println!("cargo:include={}", out_file.display());
|
println!("cargo:include={}", out_file.display());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,12 @@ 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,6 +82,9 @@ 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),
|
||||||
@ -254,3 +257,5 @@ 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;
|
||||||
|
|||||||
30
libs/migrate/m20260420_000001_create_room_attachment.rs
Normal file
30
libs/migrate/m20260420_000001_create_room_attachment.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
35
libs/migrate/m20260420_000002_add_push_subscription.rs
Normal file
35
libs/migrate/m20260420_000002_add_push_subscription.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
15
libs/migrate/sql/m20260420_000001_create_room_attachment.sql
Normal file
15
libs/migrate/sql/m20260420_000001_create_room_attachment.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- 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;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
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,6 +114,7 @@ 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;
|
||||||
@ -125,6 +126,7 @@ 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;
|
||||||
|
|||||||
22
libs/models/rooms/room_attachment.rs
Normal file
22
libs/models/rooms/room_attachment.rs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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,6 +13,8 @@ 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,6 +18,10 @@ 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 {
|
||||||
@ -29,6 +33,8 @@ 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,6 +47,9 @@ 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,6 +11,8 @@ 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,
|
||||||
@ -87,6 +89,8 @@ 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, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::error::RoomError;
|
use crate::error::RoomError;
|
||||||
use crate::metrics::RoomMetrics;
|
use crate::metrics::RoomMetrics;
|
||||||
@ -720,6 +720,7 @@ 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),
|
||||||
@ -736,6 +737,21 @@ 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,6 +73,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -388,8 +389,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(sender_id) = msg.sender_id {
|
if let Some(mid) = msg.model_id {
|
||||||
ai_model::Entity::find_by_id(sender_id)
|
ai_model::Entity::find_by_id(mid)
|
||||||
.one(&self.db)
|
.one(&self.db)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@ -429,6 +430,7 @@ 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;
|
pub use service::{RoomService, PushNotificationFn};
|
||||||
pub use types::{RoomEventType, *};
|
pub use types::{RoomEventType, *};
|
||||||
|
|||||||
@ -9,6 +9,9 @@ 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,
|
||||||
@ -17,6 +20,24 @@ 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)
|
||||||
@ -60,6 +81,23 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +159,9 @@ 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,
|
||||||
@ -198,6 +239,9 @@ 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 {
|
||||||
@ -264,6 +308,9 @@ 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?;
|
||||||
@ -367,4 +414,20 @@ 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.sender_id)
|
.filter_map(|m| m.model_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.sender_id.and_then(|id| ai_names.get(&id).cloned()),
|
"ai" => msg.model_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,6 +96,7 @@ 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();
|
||||||
@ -146,6 +147,7 @@ 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(),
|
||||||
@ -275,6 +277,7 @@ impl RoomService {
|
|||||||
send_at: now,
|
send_at: now,
|
||||||
revoked: None,
|
revoked: None,
|
||||||
revoked_by: None,
|
revoked_by: None,
|
||||||
|
highlighted_content: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,12 @@ 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 {
|
||||||
@ -261,10 +267,20 @@ impl RoomService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
notification: super::NotificationResponse,
|
notification: super::NotificationResponse,
|
||||||
) {
|
) {
|
||||||
let event = super::NotificationEvent::new(notification);
|
let event = super::NotificationEvent::new(notification.clone());
|
||||||
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,6 +325,7 @@ 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,6 +11,9 @@ 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,
|
||||||
@ -21,6 +24,29 @@ 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));
|
||||||
@ -66,6 +92,22 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,6 +198,9 @@ 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 {
|
||||||
@ -232,6 +277,9 @@ 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(),
|
||||||
@ -303,6 +351,9 @@ 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
|
||||||
@ -342,4 +393,49 @@ 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,5 +1,6 @@
|
|||||||
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};
|
||||||
@ -11,56 +12,89 @@ impl RoomService {
|
|||||||
pub async fn room_message_search(
|
pub async fn room_message_search(
|
||||||
&self,
|
&self,
|
||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
query: &str,
|
request: RoomMessageSearchRequest,
|
||||||
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 query.trim().is_empty() {
|
if request.q.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(limit.unwrap_or(20), 100);
|
let limit = std::cmp::min(request.limit.unwrap_or(20), 100);
|
||||||
let offset = offset.unwrap_or(0);
|
let offset = request.offset.unwrap_or(0);
|
||||||
|
|
||||||
// PostgreSQL full-text search via raw SQL with parameterized query.
|
// Build dynamic WHERE conditions
|
||||||
// plainto_tsquery('simple', $1) is injection-safe — it treats input as text.
|
let mut conditions = vec![
|
||||||
let sql = r#"
|
"room = $1".to_string(),
|
||||||
|
"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 room = $1
|
WHERE {}
|
||||||
AND content_tsv @@ plainto_tsquery('simple', $2)
|
|
||||||
AND revoked IS NULL
|
|
||||||
ORDER BY send_at DESC
|
ORDER BY send_at DESC
|
||||||
LIMIT $3 OFFSET $4"#;
|
LIMIT ${} OFFSET ${}"#,
|
||||||
|
where_clause,
|
||||||
let stmt = Statement::from_sql_and_values(
|
param_index,
|
||||||
DbBackend::Postgres,
|
param_index + 1
|
||||||
sql,
|
|
||||||
vec![
|
|
||||||
room_id.into(),
|
|
||||||
query.trim().into(),
|
|
||||||
limit.into(),
|
|
||||||
offset.into(),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows: Vec<room_message::Model> = self
|
params.push(limit.into());
|
||||||
.db
|
params.push(offset.into());
|
||||||
.query_all_raw(stmt)
|
|
||||||
.await?
|
let stmt = Statement::from_sql_and_values(DbBackend::Postgres, &sql, params);
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
let rows = self.db.query_all_raw(stmt).await?;
|
||||||
let sender_type = row
|
|
||||||
.try_get::<String>("", "sender_type")
|
// Parse results and build response with highlighted content
|
||||||
.map(|s| match s.as_str() {
|
let mut results: Vec<super::RoomMessageResponse> = Vec::new();
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
|
||||||
|
let sender_type = match sender_type_str.as_str() {
|
||||||
"admin" => models::rooms::MessageSenderType::Admin,
|
"admin" => models::rooms::MessageSenderType::Admin,
|
||||||
"owner" => models::rooms::MessageSenderType::Owner,
|
"owner" => models::rooms::MessageSenderType::Owner,
|
||||||
"ai" => models::rooms::MessageSenderType::Ai,
|
"ai" => models::rooms::MessageSenderType::Ai,
|
||||||
@ -68,82 +102,87 @@ impl RoomService {
|
|||||||
"tool" => models::rooms::MessageSenderType::Tool,
|
"tool" => models::rooms::MessageSenderType::Tool,
|
||||||
"guest" => models::rooms::MessageSenderType::Guest,
|
"guest" => models::rooms::MessageSenderType::Guest,
|
||||||
_ => models::rooms::MessageSenderType::Member,
|
_ => models::rooms::MessageSenderType::Member,
|
||||||
})
|
};
|
||||||
.unwrap_or(models::rooms::MessageSenderType::Member);
|
|
||||||
|
|
||||||
let content_type = row
|
let content_type_str = row.try_get::<String>("", "content_type").unwrap_or_default();
|
||||||
.try_get::<String>("", "content_type")
|
let content_type = match content_type_str.as_str() {
|
||||||
.map(|s| match s.as_str() {
|
|
||||||
"image" => models::rooms::MessageContentType::Image,
|
"image" => models::rooms::MessageContentType::Image,
|
||||||
"audio" => models::rooms::MessageContentType::Audio,
|
"audio" => models::rooms::MessageContentType::Audio,
|
||||||
"video" => models::rooms::MessageContentType::Video,
|
"video" => models::rooms::MessageContentType::Video,
|
||||||
"file" => models::rooms::MessageContentType::File,
|
"file" => models::rooms::MessageContentType::File,
|
||||||
_ => models::rooms::MessageContentType::Text,
|
_ => models::rooms::MessageContentType::Text,
|
||||||
})
|
};
|
||||||
.unwrap_or(models::rooms::MessageContentType::Text);
|
|
||||||
|
|
||||||
room_message::Model {
|
let msg = room_message::Model {
|
||||||
id: row.try_get::<MessageId>("", "id").unwrap_or_default(),
|
id: row.try_get::<MessageId>("", "id").unwrap_or_default(),
|
||||||
seq: row.try_get::<Seq>("", "seq").unwrap_or_default(),
|
seq: row.try_get::<Seq>("", "seq").unwrap_or_default(),
|
||||||
room: row.try_get::<RoomId>("", "room").unwrap_or_default(),
|
room: row.try_get::<RoomId>("", "room").unwrap_or_default(),
|
||||||
sender_type,
|
sender_type,
|
||||||
sender_id: row
|
sender_id: row.try_get::<Option<UserId>>("", "sender_id").ok().flatten(),
|
||||||
.try_get::<Option<UserId>>("", "sender_id")
|
thread: row.try_get::<Option<RoomThreadId>>("", "thread").ok().flatten(),
|
||||||
.ok()
|
in_reply_to: row.try_get::<Option<MessageId>>("", "in_reply_to").ok().flatten(),
|
||||||
.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: row.try_get::<String>("", "content").unwrap_or_default(),
|
||||||
content_type,
|
content_type,
|
||||||
edited_at: row
|
edited_at: row.try_get::<Option<DateTimeUtc>>("", "edited_at").ok().flatten(),
|
||||||
.try_get::<Option<DateTimeUtc>>("", "edited_at")
|
send_at: row.try_get::<DateTimeUtc>("", "send_at").unwrap_or_default(),
|
||||||
.ok()
|
revoked: row.try_get::<Option<DateTimeUtc>>("", "revoked").ok().flatten(),
|
||||||
.flatten(),
|
revoked_by: row.try_get::<Option<UserId>>("", "revoked_by").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,
|
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);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Efficient COUNT query.
|
// COUNT query for total (without pagination)
|
||||||
let count_sql = r#"
|
let mut count_conditions = vec![
|
||||||
SELECT COUNT(*) AS count
|
"room = $1".to_string(),
|
||||||
FROM room_message
|
"content_tsv @@ plainto_tsquery('simple', $2)".to_string(),
|
||||||
WHERE room = $1
|
"revoked IS NULL".to_string(),
|
||||||
AND content_tsv @@ plainto_tsquery('simple', $2)
|
];
|
||||||
AND revoked IS NULL"#;
|
let mut count_params: Vec<sea_orm::Value> = vec![room_id.into(), request.q.trim().into()];
|
||||||
|
let mut count_param_idx = 3;
|
||||||
|
|
||||||
let count_stmt = Statement::from_sql_and_values(
|
if let Some(start_time) = request.start_time {
|
||||||
DbBackend::Postgres,
|
count_conditions.push(format!("send_at >= ${}", count_param_idx));
|
||||||
count_sql,
|
count_params.push(start_time.into());
|
||||||
vec![room_id.into(), query.trim().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: response_messages,
|
messages: results,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,11 @@ 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());
|
||||||
@ -54,6 +59,7 @@ 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,
|
||||||
}
|
}
|
||||||
@ -69,6 +75,7 @@ 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()));
|
||||||
@ -85,6 +92,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,6 +531,12 @@ 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 {
|
||||||
@ -975,7 +989,9 @@ 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;
|
||||||
let ai_display_name = ai_display_name;
|
// Clone before closure so closure captures clone, not the original.
|
||||||
|
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;
|
||||||
@ -988,6 +1004,8 @@ 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,
|
||||||
@ -995,6 +1013,7 @@ 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;
|
||||||
|
|
||||||
@ -1026,6 +1045,7 @@ 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(),
|
||||||
@ -1062,7 +1082,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.clone()),
|
display_name: Some(ai_display_name_for_final.clone()),
|
||||||
in_reply_to: None,
|
in_reply_to: None,
|
||||||
reactions: None,
|
reactions: None,
|
||||||
message_id: None,
|
message_id: None,
|
||||||
@ -1092,6 +1112,7 @@ 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;
|
||||||
}
|
}
|
||||||
@ -1134,6 +1155,7 @@ 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
|
||||||
@ -1172,6 +1194,7 @@ 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();
|
||||||
@ -1184,6 +1207,7 @@ 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, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, 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, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct RoomMemberResponse {
|
pub struct RoomMemberResponse {
|
||||||
pub room: Uuid,
|
pub room: Uuid,
|
||||||
pub user: Uuid,
|
pub user: Uuid,
|
||||||
@ -192,6 +192,17 @@ 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,
|
||||||
@ -208,6 +219,16 @@ 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)]
|
||||||
@ -285,6 +306,8 @@ pub enum NotificationType {
|
|||||||
RoomCreated,
|
RoomCreated,
|
||||||
RoomDeleted,
|
RoomDeleted,
|
||||||
SystemAnnouncement,
|
SystemAnnouncement,
|
||||||
|
ProjectInvitation,
|
||||||
|
WorkspaceInvitation,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@ -43,6 +43,10 @@ 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 }
|
||||||
@ -65,6 +69,7 @@ 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,6 +20,11 @@ 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,
|
||||||
@ -31,6 +36,48 @@ 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 {
|
||||||
@ -101,6 +148,42 @@ 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<
|
||||||
@ -162,6 +245,47 @@ 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(),
|
||||||
@ -172,6 +296,7 @@ impl AppService {
|
|||||||
Some(task_service.clone()),
|
Some(task_service.clone()),
|
||||||
logs.clone(),
|
logs.clone(),
|
||||||
None,
|
None,
|
||||||
|
push_fn,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build WsTokenService
|
// Build WsTokenService
|
||||||
@ -187,6 +312,8 @@ impl AppService {
|
|||||||
room,
|
room,
|
||||||
ws_token,
|
ws_token,
|
||||||
queue_producer: message_producer,
|
queue_producer: message_producer,
|
||||||
|
storage,
|
||||||
|
push,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -324,6 +324,40 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
91
libs/service/push.rs
Normal file
91
libs/service/push.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
60
libs/service/storage.rs
Normal file
60
libs/service/storage.rs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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,6 +19,12 @@ 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)]
|
||||||
@ -121,6 +127,18 @@ 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?
|
||||||
@ -140,6 +158,9 @@ 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()),
|
||||||
};
|
};
|
||||||
@ -189,6 +210,9 @@ 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()),
|
||||||
};
|
};
|
||||||
@ -197,4 +221,26 @@ 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,6 +373,40 @@ 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,6 +19,7 @@
|
|||||||
"@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",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
"@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
Normal file
51
public/sw.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// 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
Normal file
814
room.md
Normal file
@ -0,0 +1,814 @@
|
|||||||
|
# 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 } from 'lucide-react';
|
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing } 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';
|
||||||
@ -10,10 +10,19 @@ 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);
|
||||||
@ -80,6 +89,7 @@ export function SettingsPreferences() {
|
|||||||
marketing_enabled: marketingEnabled,
|
marketing_enabled: marketingEnabled,
|
||||||
security_enabled: securityEnabled,
|
security_enabled: securityEnabled,
|
||||||
product_enabled: productEnabled,
|
product_enabled: productEnabled,
|
||||||
|
push_enabled: pushEnabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -146,6 +156,46 @@ 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,26 +1,39 @@
|
|||||||
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';
|
||||||
|
|
||||||
/** Map model IDs to colored circles (fallback for AI sender avatars) */
|
/** Derive a readable label from the display name for the tooltip. */
|
||||||
export function ModelIcon({ modelId, className }: { modelId?: string; className?: string }): ReactNode {
|
export function modelDisplayLabel(displayName?: string): string {
|
||||||
const colors: Record<string, string> = {
|
if (!displayName) return 'AI';
|
||||||
claude: 'bg-orange-500',
|
// e.g. "anthropic/claude-3-5-sonnet-20241022" → "Claude 3 5 Sonnet 20241022"
|
||||||
gpt: 'bg-green-600',
|
const last = displayName.includes('/')
|
||||||
gemini: 'bg-blue-600',
|
? displayName.split('/').pop()!
|
||||||
deepseek: 'bg-purple-600',
|
: displayName;
|
||||||
o1: 'bg-pink-600',
|
return last
|
||||||
o3: 'bg-pink-700',
|
.replace(/[-_]/g, ' ')
|
||||||
o4: 'bg-pink-800',
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
ai: 'bg-primary',
|
|
||||||
};
|
|
||||||
|
|
||||||
const color = modelId ? (colors[modelId.toLowerCase()] ?? 'bg-primary') : 'bg-primary';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-block h-5 w-5 rounded-full ${color} flex items-center justify-center ${className ?? ''}`}
|
|
||||||
title={modelId}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-bold text-white">A</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelIconProps {
|
||||||
|
/** 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 (
|
||||||
|
<LobehubModelIcon
|
||||||
|
model={model}
|
||||||
|
type="avatar"
|
||||||
|
size={20}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
|
import { getSenderDisplayName, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
|
||||||
import { MessageReactions } from './MessageReactions';
|
import { MessageReactions } from './MessageReactions';
|
||||||
import { ReactionPicker } from './ReactionPicker';
|
import { ReactionPicker } from './ReactionPicker';
|
||||||
|
|
||||||
@ -78,7 +78,6 @@ 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;
|
||||||
@ -209,7 +208,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 modelId={senderModelId} /> : initial}
|
{isAi ? <ModelIcon model={displayName} /> : initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</button>
|
</button>
|
||||||
@ -318,12 +317,10 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap break-words">
|
|
||||||
<MessageContent
|
<MessageContent
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
onMentionClick={handleMentionClick}
|
onMentionClick={handleMentionClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Streaming cursor */}
|
{/* Streaming cursor */}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders message content — parses @[type:id:label] mentions into styled spans.
|
* Renders message content — markdown with @[type:id:label] mentions.
|
||||||
|
* 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 {
|
||||||
@ -11,35 +16,23 @@ interface MessageContentProps {
|
|||||||
onMentionClick?: (type: string, id: string, label: string) => void;
|
onMentionClick?: (type: string, id: string, label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses @[type:id:label] patterns from message content */
|
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
|
||||||
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;
|
|
||||||
|
|
||||||
while ((match = RE.exec(content)) !== null) {
|
interface MentionInfo {
|
||||||
// Text before this match
|
type: string;
|
||||||
if (match.index > lastIndex) {
|
id: string;
|
||||||
parts.push({ type: 'text', text: content.slice(lastIndex, match.index) });
|
label: string;
|
||||||
}
|
}
|
||||||
parts.push({
|
|
||||||
type: 'mention',
|
/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
|
||||||
mention: {
|
function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
|
||||||
type: match[1],
|
const mentions: MentionInfo[] = [];
|
||||||
id: match[2],
|
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
|
||||||
label: match[3],
|
const idx = mentions.length;
|
||||||
},
|
mentions.push({ type, id, label });
|
||||||
|
return `\u200BMENTION_${idx}\u200B`; // zero-width spaces prevent markdown parsing
|
||||||
});
|
});
|
||||||
lastIndex = RE.lastIndex;
|
return { safeContent, mentions };
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining text
|
|
||||||
if (lastIndex < content.length) {
|
|
||||||
parts.push({ type: 'text', text: content.slice(lastIndex) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMentionStyle(type: string): string {
|
function getMentionStyle(type: string): string {
|
||||||
@ -52,42 +45,149 @@ function getMentionStyle(type: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageContent({ content, onMentionClick }: MessageContentProps) {
|
/** Restore mention placeholders inside a text node into React elements */
|
||||||
const parts = parseContent(content);
|
function restoreMentions(text: string, mentions: MentionInfo[], onMentionClick?: (type: string, id: string, label: string) => void): React.ReactNode[] {
|
||||||
|
const MENTION_PLACEHOLDER_RE = /\u200BMENTION_(\d+)\u200B/g;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
return (
|
while ((match = MENTION_PLACEHOLDER_RE.exec(text)) !== null) {
|
||||||
<div
|
if (match.index > lastIndex) {
|
||||||
className={cn(
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
'text-sm text-foreground',
|
}
|
||||||
'max-w-full min-w-0 break-words whitespace-pre-wrap',
|
const idx = parseInt(match[1], 10);
|
||||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
|
const m = mentions[idx];
|
||||||
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
|
if (m) {
|
||||||
)}
|
parts.push(
|
||||||
>
|
|
||||||
{parts.map((part, i) =>
|
|
||||||
part.type === 'text' ? (
|
|
||||||
<span key={i}>{part.text}</span>
|
|
||||||
) : (
|
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={`mention-${idx}`}
|
||||||
role={onMentionClick ? 'button' : undefined}
|
role={onMentionClick ? 'button' : undefined}
|
||||||
tabIndex={onMentionClick ? 0 : undefined}
|
tabIndex={onMentionClick ? 0 : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
|
||||||
getMentionStyle(part.mention!.type),
|
getMentionStyle(m.type),
|
||||||
)}
|
)}
|
||||||
onClick={() => onMentionClick?.(part.mention!.type, part.mention!.id, part.mention!.label)}
|
onClick={() => onMentionClick?.(m.type, m.id, m.label)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onMentionClick(part.mention!.type, part.mention!.id, part.mention!.label);
|
onMentionClick(m.type, m.id, m.label);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>@{part.mention!.label}</span>
|
@{m.label}
|
||||||
</span>
|
</span>,
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-[15px] text-foreground',
|
||||||
|
'max-w-full min-w-0 break-words',
|
||||||
|
'[&_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',
|
||||||
|
'[&_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
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
p: ({ children }) => {
|
||||||
|
// Restore mentions in paragraph text nodes
|
||||||
|
if (typeof children === 'string') {
|
||||||
|
return <p>{restoreMentions(children, mentions, onMentionClick)}</p>;
|
||||||
|
}
|
||||||
|
// Children may be an array of strings/elements
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
const restored = children.map((child) => {
|
||||||
|
if (typeof child === 'string') {
|
||||||
|
return restoreMentions(child, mentions, onMentionClick);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
return <p>{restored}</p>;
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -15,6 +15,7 @@ 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;
|
||||||
@ -243,6 +244,7 @@ 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);
|
||||||
@ -338,8 +340,14 @@ 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 {
|
||||||
const res = await onUploadFile(file);
|
// Compress image before upload (only if it's an image and > 500KB)
|
||||||
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();
|
let uploadFile = file;
|
||||||
|
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,6 +178,9 @@ 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;
|
||||||
@ -237,6 +240,19 @@ 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.
|
||||||
}
|
}
|
||||||
@ -396,6 +412,16 @@ 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(
|
||||||
@ -407,13 +433,22 @@ 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 — update reactions if provided
|
// Message already exists (e.g. created by streaming chunk) —
|
||||||
if (payload.reactions !== undefined) {
|
// merge server-side fields (display_name, reactions) that the
|
||||||
|
// 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], reactions: payload.reactions };
|
updated[existingIdx] = {
|
||||||
|
...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
|
||||||
@ -437,9 +472,16 @@ 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 }) => {
|
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
setStreamingContent((prev) => {
|
setStreamingContent((prev) => {
|
||||||
prev.delete(chunk.message_id);
|
prev.delete(chunk.message_id);
|
||||||
@ -480,6 +522,7 @@ 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',
|
||||||
@ -493,7 +536,7 @@ export function RoomProvider({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||||
if (!activeRoomIdRef.current) return;
|
if (payload.room_id !== 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;
|
||||||
@ -589,26 +632,21 @@ export function RoomProvider({
|
|||||||
setWsError(error.message);
|
setWsError(error.message);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ wsToken: wsToken ?? undefined },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setWsClient(client);
|
setWsClient(client);
|
||||||
wsClientRef.current = client;
|
wsClientRef.current = client;
|
||||||
|
|
||||||
return () => {
|
// Connect immediately — connect() fetches its own token if needed
|
||||||
client.disconnect();
|
client.connect().catch((e) => {
|
||||||
wsClientRef.current = null;
|
|
||||||
};
|
|
||||||
}, [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);
|
console.error('[RoomContext] WS connect error:', e);
|
||||||
});
|
});
|
||||||
}, [wsClient]);
|
|
||||||
|
return () => {
|
||||||
|
client.disconnect(); // Intentional disconnect on unmount — no reconnect
|
||||||
|
wsClientRef.current = null;
|
||||||
|
};
|
||||||
|
}, []); // ← empty deps: create once on mount
|
||||||
|
|
||||||
const connectWs = useCallback(async () => {
|
const connectWs = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
const client = wsClientRef.current;
|
||||||
|
|||||||
56
src/hooks/useImageCompress.ts
Normal file
56
src/hooks/useImageCompress.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
170
src/hooks/usePushNotification.ts
Normal file
170
src/hooks/usePushNotification.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'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,8 +249,12 @@ export class RoomWsClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(graceful = false): void {
|
||||||
|
// Only permanently disable reconnect on intentional disconnect (user action).
|
||||||
|
// Graceful disconnect (cleanup from effect swap) allows reconnect to continue.
|
||||||
|
if (!graceful) {
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
|
}
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
@ -1011,7 +1015,10 @@ export class RoomWsClient {
|
|||||||
break;
|
break;
|
||||||
case 'room.reaction_updated':
|
case 'room.reaction_updated':
|
||||||
case 'room_reaction_updated':
|
case 'room_reaction_updated':
|
||||||
this.callbacks.onRoomReactionUpdated?.(event.data as RoomReactionUpdatedPayload);
|
this.callbacks.onRoomReactionUpdated?.({
|
||||||
|
...(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
|
||||||
@ -1063,9 +1070,12 @@ 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.request('room.subscribe', { room_id: roomId });
|
await this.requestWs<SubscribeData>('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).
|
||||||
@ -1074,7 +1084,7 @@ export class RoomWsClient {
|
|||||||
}
|
}
|
||||||
for (const projectName of this.subscribedProjects) {
|
for (const projectName of this.subscribedProjects) {
|
||||||
try {
|
try {
|
||||||
await this.request('project.subscribe', { project_name: projectName });
|
await this.requestWs<SubscribeData>('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);
|
||||||
}
|
}
|
||||||
@ -1089,7 +1099,9 @@ 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);
|
||||||
const jitter = Math.random() * cappedDelay;
|
// Ensure minimum 500ms delay to avoid hitting backend 30s connection cooldown
|
||||||
|
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,6 +186,8 @@ 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 {
|
||||||
@ -283,6 +285,7 @@ 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
Normal file
17
src/types/browser-image-compression.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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