diff --git a/.gitignore b/.gitignore index a1973fc..17f8a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ frontend/.astro/ frontend/dist/ frontend/node_modules/ +mcp-server/node_modules/ backend/target/ backend/.loco-start.err.log backend/.loco-start.out.log +backend/backend-start.log +backend/storage/ai_embedding_models/ diff --git a/README.md b/README.md index 15359c3..2b7073d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Monorepo for the Termi blog system. . ├─ frontend/ # Astro blog frontend ├─ backend/ # Loco.rs backend and admin +├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags ├─ .codex/ # Codex workspace config └─ .vscode/ # Editor workspace config ``` @@ -22,6 +23,12 @@ From the repository root: .\dev.ps1 ``` +Frontend + backend + MCP: + +```powershell +.\dev.ps1 -WithMcp +``` + Only frontend: ```powershell @@ -34,11 +41,18 @@ Only backend: .\dev.ps1 -BackendOnly ``` +Only MCP: + +```powershell +.\dev.ps1 -McpOnly +``` + Direct scripts: ```powershell .\start-frontend.ps1 .\start-backend.ps1 +.\start-mcp.ps1 ``` ### Frontend @@ -57,6 +71,32 @@ $env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-ap cargo loco start 2>&1 ``` +### MCP Server + +```powershell +.\start-mcp.ps1 +``` + +Default MCP endpoint: + +```text +http://127.0.0.1:5151/mcp +``` + +Default local development API key: + +```text +termi-mcp-local-dev-key +``` + +The MCP server wraps real backend APIs for: + +- Listing, reading, creating, updating, and deleting Markdown posts +- Listing, creating, updating, and deleting categories +- Listing, creating, updating, and deleting tags +- Reading and updating public site settings +- Rebuilding the AI index + ## Repo Name Recommended repository name: `termi-blog` diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5dde918..8768958 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -28,6 +28,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -47,6 +48,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -142,6 +161,23 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "argon2" version = "0.5.3" @@ -160,6 +196,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -242,6 +287,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" version = "0.8.8" @@ -386,6 +474,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -412,6 +506,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -427,6 +527,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -518,6 +627,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d75b8252ed252f881d1dc4482ae3c3854df6ee8183c1906bac50ff358f4f89f" +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" @@ -556,12 +671,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -574,6 +701,15 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.57" @@ -695,6 +831,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -734,6 +876,21 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -763,6 +920,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.3" @@ -791,12 +961,41 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -913,6 +1112,12 @@ dependencies = [ "regex", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -981,6 +1186,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1002,7 +1216,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1016,6 +1240,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1073,6 +1328,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1147,7 +1423,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1181,6 +1457,26 @@ dependencies = [ "regex", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1197,6 +1493,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etcetera" version = "0.8.0" @@ -1219,12 +1521,73 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastembed" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1334,6 +1697,27 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1529,9 +1913,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1548,6 +1934,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -1590,6 +1986,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1617,7 +2043,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1625,6 +2051,13 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -1653,6 +2086,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.2", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -1671,6 +2125,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "home" version = "0.5.12" @@ -1773,6 +2233,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1785,13 +2246,46 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1803,9 +2297,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1962,6 +2458,46 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "include_dir" version = "0.7.4" @@ -1993,6 +2529,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "inherent" version = "1.0.13" @@ -2030,7 +2579,7 @@ version = "1.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f40e41efb5f592d3a0764f818e2f08e5e21c4f368126f74f37c81bd4af7a0c6" dependencies = [ - "console", + "console 0.16.3", "once_cell", "pest", "pest_derive", @@ -2040,6 +2589,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -2090,6 +2650,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2122,7 +2691,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -2166,6 +2735,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "lettre" version = "0.11.19" @@ -2173,7 +2748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chumsky", "email-encoding", "email_address", @@ -2201,6 +2776,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -2340,12 +2925,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rust2" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" + [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2386,6 +3008,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2471,6 +3113,38 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2488,6 +3162,38 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2513,6 +3219,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify" version = "8.2.0" @@ -2575,6 +3287,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2612,6 +3333,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2622,6 +3354,12 @@ dependencies = [ "libm", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.37.3" @@ -2643,6 +3381,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "opendal" version = "0.54.1" @@ -2651,7 +3411,7 @@ checksum = "42afda58fa2cf50914402d132cc1caacff116a85d10c72ab2082bb7c50021754" dependencies = [ "anyhow", "backon", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -2669,6 +3429,56 @@ dependencies = [ "uuid", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -2678,6 +3488,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq 3.3.0", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2761,13 +3595,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2780,6 +3626,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2908,7 +3763,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", + "der 0.7.10", "pkcs8", "spki", ] @@ -2919,7 +3774,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -2935,12 +3790,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3050,6 +3927,25 @@ dependencies = [ "yansi", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "psm" version = "0.1.30" @@ -3080,6 +3976,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3090,6 +4007,61 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3199,6 +4171,93 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redis" version = "0.31.0" @@ -3239,6 +4298,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -3289,24 +4359,36 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower 0.5.3", "tower-http", @@ -3316,6 +4398,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -3327,6 +4410,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -3519,6 +4608,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3545,6 +4635,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3563,6 +4664,15 @@ dependencies = [ "sdd", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3762,6 +4872,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.26.0" @@ -4055,6 +5188,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -4130,6 +5272,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spin" version = "0.9.8" @@ -4146,7 +5299,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", ] [[package]] @@ -4168,7 +5333,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -4248,7 +5413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.11.0", "byteorder", @@ -4295,7 +5460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.11.0", "byteorder", @@ -4479,6 +5644,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -4541,16 +5727,19 @@ dependencies = [ name = "termi-api" version = "0.1.0" dependencies = [ + "async-stream", "async-trait", "axum", "axum-extra", "chrono", + "fastembed", "fluent-templates", "include_dir", "insta", "loco-rs", "migration", "regex", + "reqwest", "rstest", "sea-orm", "serde", @@ -4615,6 +5804,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -4672,6 +5875,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash 0.8.12", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.50.0" @@ -4714,6 +5950,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5091,6 +6337,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -5115,6 +6370,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5127,6 +6388,56 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der 0.8.0", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5151,6 +6462,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5176,6 +6493,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "validator" version = "0.20.0" @@ -5399,6 +6727,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5417,6 +6754,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -5427,6 +6770,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5436,6 +6795,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -5477,6 +6842,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5847,6 +7223,12 @@ dependencies = [ "tap", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -5990,3 +7372,27 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f5b31e9..b43851b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -42,6 +42,9 @@ unic-langid = { version = "0.9" } # /view engine axum-extra = { version = "0.10", features = ["form"] } tower-http = { version = "0.6", features = ["cors"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +fastembed = "5.1" +async-stream = "0.3" [[bin]] name = "termi_api-cli" diff --git a/backend/assets/views/admin/base.html b/backend/assets/views/admin/base.html index f5652f2..f189df0 100644 --- a/backend/assets/views/admin/base.html +++ b/backend/assets/views/admin/base.html @@ -47,7 +47,8 @@ button, input, - textarea { + textarea, + select { font: inherit; } @@ -455,7 +456,8 @@ } .field input, - .field textarea { + .field textarea, + .field select { width: 100%; border: 1px solid var(--line); border-radius: 14px; @@ -676,6 +678,27 @@ location.reload(); } + + async function adminDelete(url, successMessage) { + const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。"); + if (!confirmed) { + return; + } + + const response = await fetch(url, { + method: "DELETE" + }); + + if (!response.ok) { + throw new Error(await response.text() || "request failed"); + } + + if (successMessage) { + alert(successMessage); + } + + location.reload(); + } {% block page_scripts %}{% endblock %} diff --git a/backend/assets/views/admin/comments.html b/backend/assets/views/admin/comments.html index dd925a4..45bddeb 100644 --- a/backend/assets/views/admin/comments.html +++ b/backend/assets/views/admin/comments.html @@ -1,11 +1,74 @@ {% extends "admin/base.html" %} {% block main_content %} +
+
+
+

评论筛选

+
按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。
+
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + 清空 +
+
+ + +
+

评论队列

-
处理前台真实评论,并能一键跳到对应文章页核对展示。
+
处理前台真实评论,并能一键跳到对应文章或段落核对上下文。
@@ -16,7 +79,7 @@ ID 作者 / 文章 - 内容 + 内容与上下文 状态 时间 操作 @@ -30,12 +93,32 @@
{{ row.author }} {{ row.post_slug }} + {% if row.scope == "paragraph" %} + {{ row.scope_label }} + {% else %} + {{ row.scope_label }} + {% endif %} {% if row.frontend_url %} - 跳到前台文章 + + {% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %} + + {% endif %} +
+ + +
+ {{ row.content }} + {% if row.reply_target != "-" %} + 回复目标:{{ row.reply_target }} + {% endif %} + {% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %} + 段落上下文:{{ row.paragraph_excerpt }} + {% endif %} + {% if row.scope == "paragraph" and row.paragraph_key != "-" %} + 段落 key:{{ row.paragraph_key }} {% endif %}
- {{ row.content }} {% if row.approved %} 已审核 @@ -48,6 +131,7 @@
+ API
@@ -57,7 +141,7 @@ {% else %} -
暂无评论数据。
+
当前筛选条件下暂无评论数据。
{% endif %}
{% endblock %} diff --git a/backend/assets/views/admin/site_settings.html b/backend/assets/views/admin/site_settings.html index 1f5cdbe..7722505 100644 --- a/backend/assets/views/admin/site_settings.html +++ b/backend/assets/views/admin/site_settings.html @@ -5,7 +5,7 @@

站点资料

-
保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。
+
保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。
@@ -74,11 +74,54 @@ + +
+ +
关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。
+
+
+ + +
+
+ + +
+ + +
这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入本地 NewAPI 网关,未配置时前台仍可做本地检索,但不会生成完整聊天回答。
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。
+
-
保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。
+
文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。
@@ -89,12 +132,18 @@ {% endblock %} diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 93cf863..6d93c6a 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -13,6 +13,10 @@ mod m20260328_000002_create_site_settings; mod m20260328_000003_add_site_url_to_site_settings; mod m20260328_000004_add_posts_search_index; mod m20260328_000005_categories; +mod m20260328_000006_add_ai_to_site_settings; +mod m20260328_000007_create_ai_chunks; +mod m20260328_000008_enable_pgvector_for_ai_chunks; +mod m20260328_000009_add_paragraph_comments; pub struct Migrator; #[async_trait::async_trait] @@ -30,6 +34,10 @@ impl MigratorTrait for Migrator { Box::new(m20260328_000003_add_site_url_to_site_settings::Migration), Box::new(m20260328_000004_add_posts_search_index::Migration), Box::new(m20260328_000005_categories::Migration), + Box::new(m20260328_000006_add_ai_to_site_settings::Migration), + Box::new(m20260328_000007_create_ai_chunks::Migration), + Box::new(m20260328_000008_enable_pgvector_for_ai_chunks::Migration), + Box::new(m20260328_000009_add_paragraph_comments::Migration), // inject-above (do not remove this comment) ] } diff --git a/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs b/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs new file mode 100644 index 0000000..a6349f1 --- /dev/null +++ b/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs @@ -0,0 +1,175 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager.has_column("site_settings", "ai_enabled").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_enabled")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_provider").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_provider")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_api_base").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_api_base")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_api_key").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_api_key")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_chat_model").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_chat_model")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_embedding_model") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_embedding_model")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_system_prompt") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_system_prompt")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_top_k").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_top_k")).integer().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_chunk_size").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_chunk_size")).integer().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_last_indexed_at") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column( + ColumnDef::new(Alias::new("ai_last_indexed_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in [ + "ai_last_indexed_at", + "ai_chunk_size", + "ai_top_k", + "ai_system_prompt", + "ai_embedding_model", + "ai_chat_model", + "ai_api_key", + "ai_api_base", + "ai_provider", + "ai_enabled", + ] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000007_create_ai_chunks.rs b/backend/migration/src/m20260328_000007_create_ai_chunks.rs new file mode 100644 index 0000000..89f6188 --- /dev/null +++ b/backend/migration/src/m20260328_000007_create_ai_chunks.rs @@ -0,0 +1,33 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "ai_chunks", + &[ + ("id", ColType::PkAuto), + ("source_slug", ColType::String), + ("source_title", ColType::StringNull), + ("source_path", ColType::StringNull), + ("source_type", ColType::String), + ("chunk_index", ColType::Integer), + ("content", ColType::Text), + ("content_preview", ColType::StringNull), + ("embedding", ColType::JsonBinaryNull), + ("word_count", ColType::IntegerNull), + ], + &[], + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + drop_table(manager, "ai_chunks").await + } +} diff --git a/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs b/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs new file mode 100644 index 0000000..a1d74b1 --- /dev/null +++ b/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs @@ -0,0 +1,69 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const AI_CHUNKS_TABLE: &str = "ai_chunks"; +const VECTOR_INDEX_NAME: &str = "idx_ai_chunks_embedding_hnsw"; +const EMBEDDING_DIMENSION: i32 = 384; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("CREATE EXTENSION IF NOT EXISTS vector") + .await?; + + if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding") + .await?; + } + + if !manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared(&format!( + "ALTER TABLE ai_chunks ADD COLUMN embedding vector({EMBEDDING_DIMENSION})" + )) + .await?; + } + + manager + .get_connection() + .execute_unprepared("TRUNCATE TABLE ai_chunks RESTART IDENTITY") + .await?; + + manager + .get_connection() + .execute_unprepared(&format!( + "CREATE INDEX IF NOT EXISTS {VECTOR_INDEX_NAME} ON ai_chunks USING hnsw (embedding vector_cosine_ops)" + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(&format!("DROP INDEX IF EXISTS {VECTOR_INDEX_NAME}")) + .await?; + + if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding") + .await?; + } + + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks ADD COLUMN embedding jsonb") + .await?; + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000009_add_paragraph_comments.rs b/backend/migration/src/m20260328_000009_add_paragraph_comments.rs new file mode 100644 index 0000000..73ef7b4 --- /dev/null +++ b/backend/migration/src/m20260328_000009_add_paragraph_comments.rs @@ -0,0 +1,109 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const TABLE: &str = "comments"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new(TABLE); + + if !manager.has_column(TABLE, "scope").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("scope")) + .string() + .not_null() + .default("article"), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "paragraph_key").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("paragraph_key")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "paragraph_excerpt").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("paragraph_excerpt")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "reply_to_comment_id").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("reply_to_comment_id")) + .integer() + .null(), + ) + .to_owned(), + ) + .await?; + } + + manager + .get_connection() + .execute_unprepared( + "UPDATE comments SET scope = 'article' WHERE scope IS NULL OR trim(scope) = ''", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_comments_post_scope_paragraph ON comments (post_slug, scope, paragraph_key)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_comments_post_scope_paragraph") + .await?; + + for column in [ + "reply_to_comment_id", + "paragraph_excerpt", + "paragraph_key", + "scope", + ] { + if manager.has_column(TABLE, column).await? { + manager + .alter_table( + Table::alter() + .table(Alias::new(TABLE)) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/src/app.rs b/backend/src/app.rs index 9d7d7b7..7d377bc 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -21,7 +21,9 @@ use tower_http::cors::{Any, CorsLayer}; #[allow(unused_imports)] use crate::{ controllers, initializers, - models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users}, + models::_entities::{ + ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users, + }, tasks, workers::downloader::DownloadWorker, }; @@ -69,12 +71,19 @@ impl Hooks for App { .add_route(controllers::post::routes()) .add_route(controllers::search::routes()) .add_route(controllers::site_settings::routes()) + .add_route(controllers::ai::routes()) .add_route(controllers::auth::routes()) } async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result { let cors = CorsLayer::new() .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) .allow_headers(Any); Ok(router.layer(cors)) @@ -88,10 +97,6 @@ impl Hooks for App { fn register_tasks(tasks: &mut Tasks) { // tasks-inject (do not remove) } - async fn truncate(ctx: &AppContext) -> Result<()> { - truncate_table(&ctx.db, users::Entity).await?; - Ok(()) - } async fn seed(ctx: &AppContext, base: &Path) -> Result<()> { // Seed users - use loco's default seed which handles duplicates let users_file = base.join("users.yaml"); @@ -275,44 +280,59 @@ impl Hooks for App { let item = site_settings::ActiveModel { id: Set(settings["id"].as_i64().unwrap_or(1) as i32), site_name: Set(settings["site_name"].as_str().map(ToString::to_string)), - site_short_name: Set( - settings["site_short_name"].as_str().map(ToString::to_string), - ), + site_short_name: Set(settings["site_short_name"] + .as_str() + .map(ToString::to_string)), site_url: Set(settings["site_url"].as_str().map(ToString::to_string)), site_title: Set(settings["site_title"].as_str().map(ToString::to_string)), - site_description: Set( - settings["site_description"].as_str().map(ToString::to_string), - ), + site_description: Set(settings["site_description"] + .as_str() + .map(ToString::to_string)), hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)), - hero_subtitle: Set( - settings["hero_subtitle"].as_str().map(ToString::to_string), - ), + hero_subtitle: Set(settings["hero_subtitle"] + .as_str() + .map(ToString::to_string)), owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)), - owner_title: Set( - settings["owner_title"].as_str().map(ToString::to_string), - ), + owner_title: Set(settings["owner_title"].as_str().map(ToString::to_string)), owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)), - owner_avatar_url: Set( - settings["owner_avatar_url"].as_str().and_then(|value| { + owner_avatar_url: Set(settings["owner_avatar_url"].as_str().and_then( + |value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } - }), - ), - social_github: Set( - settings["social_github"].as_str().map(ToString::to_string), - ), - social_twitter: Set( - settings["social_twitter"].as_str().map(ToString::to_string), - ), - social_email: Set( - settings["social_email"].as_str().map(ToString::to_string), - ), + }, + )), + social_github: Set(settings["social_github"] + .as_str() + .map(ToString::to_string)), + social_twitter: Set(settings["social_twitter"] + .as_str() + .map(ToString::to_string)), + social_email: Set(settings["social_email"] + .as_str() + .map(ToString::to_string)), location: Set(settings["location"].as_str().map(ToString::to_string)), tech_stack: Set(tech_stack), + ai_enabled: Set(settings["ai_enabled"].as_bool()), + ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)), + ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)), + ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)), + ai_chat_model: Set(settings["ai_chat_model"] + .as_str() + .map(ToString::to_string)), + ai_embedding_model: Set(settings["ai_embedding_model"] + .as_str() + .map(ToString::to_string)), + ai_system_prompt: Set(settings["ai_system_prompt"] + .as_str() + .map(ToString::to_string)), + ai_top_k: Set(settings["ai_top_k"].as_i64().map(|value| value as i32)), + ai_chunk_size: Set(settings["ai_chunk_size"] + .as_i64() + .map(|value| value as i32)), ..Default::default() }; let _ = item.insert(&ctx.db).await; @@ -365,4 +385,10 @@ impl Hooks for App { Ok(()) } + + async fn truncate(ctx: &AppContext) -> Result<()> { + truncate_table(&ctx.db, ai_chunks::Entity).await?; + truncate_table(&ctx.db, users::Entity).await?; + Ok(()) + } } diff --git a/backend/src/controllers/admin.rs b/backend/src/controllers/admin.rs index 44a27ce..6205e21 100644 --- a/backend/src/controllers/admin.rs +++ b/backend/src/controllers/admin.rs @@ -1,13 +1,21 @@ -use axum::{extract::{Multipart, Query, State}, Form}; +use axum::{ + extract::{Multipart, Query, State}, + Form, +}; use loco_rs::prelude::*; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; -use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags}; -use crate::services::content; +use crate::models::_entities::{ + ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, +}; +use crate::services::{ai, content}; static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false); const FRONTEND_BASE_URL: &str = "http://localhost:4321"; @@ -23,6 +31,14 @@ pub struct LoginQuery { error: Option, } +#[derive(Default, Deserialize)] +pub struct CommentAdminQuery { + scope: Option, + approved: Option, + post_slug: Option, + q: Option, +} + #[derive(Serialize)] struct HeaderAction { label: String, @@ -89,12 +105,31 @@ struct CommentRow { author: String, post_slug: String, content: String, + scope: String, + scope_label: String, + paragraph_excerpt: String, + paragraph_key: String, + reply_target: String, approved: bool, created_at: String, frontend_url: Option, api_url: String, } +#[derive(Serialize)] +struct CommentFilterState { + scope: String, + approved: String, + post_slug: String, + q: String, +} + +#[derive(Serialize)] +struct CommentFilterStat { + label: String, + value: String, +} + #[derive(Serialize)] struct TagRow { id: i32, @@ -174,13 +209,9 @@ fn url_encode(value: &str) -> String { let mut encoded = String::new(); for byte in value.as_bytes() { match byte { - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'_' - | b'.' - | b'~' => encoded.push(*byte as char), + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(*byte as char) + } b' ' => encoded.push_str("%20"), _ => encoded.push_str(&format!("%{byte:02X}")), } @@ -286,6 +317,60 @@ fn link_status_text(status: &str) -> &'static str { } } +fn comment_scope_label(scope: &str) -> &'static str { + match scope { + "paragraph" => "段落评论", + _ => "全文评论", + } +} + +fn comment_frontend_url(comment: &comments::Model) -> Option { + let slug = comment + .post_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + + let mut url = frontend_path(&format!("/articles/{slug}")); + if comment.scope == "paragraph" { + if let Some(paragraph_key) = comment + .paragraph_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + url.push('#'); + url.push_str("paragraph-"); + url.push_str(paragraph_key); + } + } + + Some(url) +} + +fn normalized_filter_value(value: Option<&str>) -> String { + value.unwrap_or_default().trim().to_string() +} + +fn comment_matches_query(comment: &comments::Model, query: &str) -> bool { + if query.is_empty() { + return true; + } + + let query = query.to_lowercase(); + let fields = [ + comment.author.as_deref().unwrap_or_default(), + comment.post_slug.as_deref().unwrap_or_default(), + comment.content.as_deref().unwrap_or_default(), + comment.paragraph_excerpt.as_deref().unwrap_or_default(), + comment.paragraph_key.as_deref().unwrap_or_default(), + ]; + + fields + .iter() + .any(|value| value.to_lowercase().contains(&query)) +} + fn page_context(title: &str, description: &str, active_nav: &str) -> Map { let mut context = Map::new(); context.insert("page_title".into(), json!(title)); @@ -312,7 +397,7 @@ fn render_admin( format::view(&view_engine.0, template, Value::Object(context)) } -fn check_auth() -> Result<()> { +pub(crate) fn check_auth() -> Result<()> { if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) { return Err(Error::Unauthorized("Not logged in".to_string())); } @@ -470,16 +555,26 @@ pub async fn index( ]; let profile = SiteProfile { - site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"), + site_name: non_empty( + site.as_ref().and_then(|item| item.site_name.as_deref()), + "未配置站点", + ), site_description: non_empty( site.as_ref() .and_then(|item| item.site_description.as_deref()), "站点简介尚未设置", ), - site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"), + site_url: non_empty( + site.as_ref().and_then(|item| item.site_url.as_deref()), + "未配置站点链接", + ), }; - let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard"); + let mut context = page_context( + "后台总览", + "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", + "dashboard", + ); context.insert( "header_actions".into(), json!([ @@ -523,7 +618,11 @@ pub async fn posts_admin( file_path: file_path_by_slug .get(&post.slug) .cloned() - .unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()), + .unwrap_or_else(|| { + content::markdown_post_path(&post.slug) + .to_string_lossy() + .to_string() + }), created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(), category_name: category_name.clone(), category_frontend_url: frontend_query_url("/articles", "category", &category_name), @@ -535,7 +634,11 @@ pub async fn posts_admin( }) .collect::>(); - let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts"); + let mut context = page_context( + "文章管理", + "核对文章、分类和标签,并可直接跳到前台详情页。", + "posts", + ); context.insert( "header_actions".into(), json!([ @@ -596,7 +699,11 @@ pub async fn posts_import( let mut files = Vec::new(); - while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? { + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + { let file_name = field .file_name() .map(ToString::to_string) @@ -642,9 +749,19 @@ pub async fn post_editor( "header_actions".into(), json!([ action("返回文章管理", "/admin/posts".to_string(), "ghost", false), - action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true), + action( + "前台预览", + frontend_path(&format!("/articles/{}", slug)), + "primary", + true + ), action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true), - action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true), + action( + "Markdown API", + format!("/api/posts/slug/{slug}/markdown"), + "ghost", + true + ), ]), ); context.insert( @@ -662,6 +779,7 @@ pub async fn post_editor( pub async fn comments_admin( view_engine: ViewEngine, + Query(query): Query, State(ctx): State, ) -> Result { check_auth()?; @@ -672,8 +790,62 @@ pub async fn comments_admin( .all(&ctx.db) .await?; + let scope_filter = normalized_filter_value(query.scope.as_deref()); + let approved_filter = normalized_filter_value(query.approved.as_deref()); + let post_slug_filter = normalized_filter_value(query.post_slug.as_deref()); + let text_filter = normalized_filter_value(query.q.as_deref()); + + let total_count = items.len(); + let article_count = items.iter().filter(|comment| comment.scope != "paragraph").count(); + let paragraph_count = items.iter().filter(|comment| comment.scope == "paragraph").count(); + let pending_count = items + .iter() + .filter(|comment| !comment.approved.unwrap_or(false)) + .count(); + + let author_by_id = items + .iter() + .map(|comment| { + ( + comment.id, + non_empty(comment.author.as_deref(), "匿名"), + ) + }) + .collect::>(); + + let post_options = items + .iter() + .filter_map(|comment| comment.post_slug.as_deref()) + .map(str::trim) + .filter(|slug| !slug.is_empty()) + .map(ToString::to_string) + .collect::>() + .into_iter() + .collect::>(); + let rows = items .iter() + .filter(|comment| { + if !scope_filter.is_empty() && comment.scope != scope_filter { + return false; + } + + if approved_filter == "true" && !comment.approved.unwrap_or(false) { + return false; + } + + if approved_filter == "false" && comment.approved.unwrap_or(false) { + return false; + } + + if !post_slug_filter.is_empty() + && comment.post_slug.as_deref().unwrap_or_default().trim() != post_slug_filter + { + return false; + } + + comment_matches_query(comment, &text_filter) + }) .map(|comment| { let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章"); CommentRow { @@ -681,19 +853,33 @@ pub async fn comments_admin( author: non_empty(comment.author.as_deref(), "匿名"), post_slug: post_slug.clone(), content: non_empty(comment.content.as_deref(), "-"), + scope: comment.scope.clone(), + scope_label: comment_scope_label(&comment.scope).to_string(), + paragraph_excerpt: non_empty(comment.paragraph_excerpt.as_deref(), "-"), + paragraph_key: non_empty(comment.paragraph_key.as_deref(), "-"), + reply_target: comment + .reply_to_comment_id + .map(|reply_id| { + let author = author_by_id + .get(&reply_id) + .cloned() + .unwrap_or_else(|| "未知评论".to_string()); + format!("#{reply_id} · {author}") + }) + .unwrap_or_else(|| "-".to_string()), approved: comment.approved.unwrap_or(false), created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(), - frontend_url: comment - .post_slug - .as_deref() - .filter(|slug| !slug.trim().is_empty()) - .map(|slug| frontend_path(&format!("/articles/{slug}"))), + frontend_url: comment_frontend_url(comment), api_url: format!("/api/comments/{}", comment.id), } }) .collect::>(); - let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments"); + let mut context = page_context( + "评论审核", + "前台真实评论会先进入这里,审核通过后再展示到文章页。", + "comments", + ); context.insert( "header_actions".into(), json!([ @@ -701,6 +887,37 @@ pub async fn comments_admin( action("评论 API", "/api/comments".to_string(), "ghost", true), ]), ); + context.insert( + "filters".into(), + json!(CommentFilterState { + scope: scope_filter, + approved: approved_filter, + post_slug: post_slug_filter, + q: text_filter, + }), + ); + context.insert("post_options".into(), json!(post_options)); + context.insert( + "stats".into(), + json!([ + CommentFilterStat { + label: "全部评论".to_string(), + value: total_count.to_string(), + }, + CommentFilterStat { + label: "全文评论".to_string(), + value: article_count.to_string(), + }, + CommentFilterStat { + label: "段落评论".to_string(), + value: paragraph_count.to_string(), + }, + CommentFilterStat { + label: "待审核".to_string(), + value: pending_count.to_string(), + }, + ]), + ); context.insert("rows".into(), json!(rows)); render_admin(&view_engine, "admin/comments.html", context) @@ -742,7 +959,8 @@ pub async fn categories_admin( .and_then(|post| post.title.as_deref()) .unwrap_or("最近文章") .to_string(), - latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))), + latest_frontend_url: latest + .map(|post| frontend_path(&format!("/articles/{}", post.slug))), frontend_url: frontend_path("/categories"), articles_url: frontend_query_url("/articles", "category", &name), api_url: format!("/api/categories/{}", category.id), @@ -758,7 +976,11 @@ pub async fn categories_admin( .then_with(|| left.name.cmp(&right.name)) }); - let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories"); + let mut context = page_context( + "分类管理", + "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", + "categories", + ); context.insert( "header_actions".into(), json!([ @@ -896,7 +1118,11 @@ pub async fn tags_admin( }) .collect::>(); - let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags"); + let mut context = page_context( + "标签管理", + "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", + "tags", + ); context.insert( "header_actions".into(), json!([ @@ -1019,7 +1245,11 @@ pub async fn reviews_admin( }) .collect::>(); - let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews"); + let mut context = page_context( + "评价管理", + "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", + "reviews", + ); context.insert( "header_actions".into(), json!([ @@ -1058,7 +1288,9 @@ pub async fn reviews_create( review_date: Set(Some(normalize_admin_text(&form.review_date))), status: Set(Some(normalize_admin_text(&form.status))), description: Set(Some(normalize_admin_text(&form.description))), - tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())), + tags: Set(Some( + serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(), + )), cover: Set(Some(normalize_admin_text(&form.cover))), ..Default::default() } @@ -1087,7 +1319,9 @@ pub async fn reviews_update( model.review_date = Set(Some(normalize_admin_text(&form.review_date))); model.status = Set(Some(normalize_admin_text(&form.status))); model.description = Set(Some(normalize_admin_text(&form.description))); - model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())); + model.tags = Set(Some( + serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(), + )); model.cover = Set(Some(normalize_admin_text(&form.cover))); let _ = model.update(&ctx.db).await?; @@ -1134,7 +1368,11 @@ pub async fn friend_links_admin( }) .collect::>(); - let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links"); + let mut context = page_context( + "友链申请", + "处理前台友链申请状态,并跳转到前台友链页或目标站点。", + "friend_links", + ); context.insert( "header_actions".into(), json!([ @@ -1159,6 +1397,13 @@ fn tech_stack_text(item: &site_settings::Model) -> String { .join("\n") } +fn indexed_at_text(item: &site_settings::Model) -> String { + item.ai_last_indexed_at + .as_ref() + .map(|value| value.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "尚未建立索引".to_string()) +} + pub async fn site_settings_admin( view_engine: ViewEngine, State(ctx): State, @@ -1171,8 +1416,13 @@ pub async fn site_settings_admin( .one(&ctx.db) .await? .ok_or(Error::NotFound)?; + let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?; - let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings"); + let mut context = page_context( + "站点设置", + "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", + "site_settings", + ); context.insert( "header_actions".into(), json!([ @@ -1201,6 +1451,17 @@ pub async fn site_settings_admin( "social_email": non_empty(item.social_email.as_deref(), ""), "owner_bio": non_empty(item.owner_bio.as_deref(), ""), "tech_stack": tech_stack_text(&item), + "ai_enabled": item.ai_enabled.unwrap_or(false), + "ai_provider": non_empty(item.ai_provider.as_deref(), &ai::provider_name(None)), + "ai_api_base": non_empty(item.ai_api_base.as_deref(), ai::default_api_base()), + "ai_api_key": non_empty(item.ai_api_key.as_deref(), ""), + "ai_chat_model": non_empty(item.ai_chat_model.as_deref(), ai::default_chat_model()), + "ai_local_embedding": ai::local_embedding_label(), + "ai_system_prompt": non_empty(item.ai_system_prompt.as_deref(), ""), + "ai_top_k": item.ai_top_k.unwrap_or(4), + "ai_chunk_size": item.ai_chunk_size.unwrap_or(1200), + "ai_last_indexed_at": indexed_at_text(&item), + "ai_chunks_count": ai_chunks_count, }), ); @@ -1217,7 +1478,10 @@ pub fn routes() -> Routes { .add("/admin/posts/import", post(posts_import)) .add("/admin/posts/{slug}/edit", get(post_editor)) .add("/admin/comments", get(comments_admin)) - .add("/admin/categories", get(categories_admin).post(categories_create)) + .add( + "/admin/categories", + get(categories_admin).post(categories_create), + ) .add("/admin/categories/{id}/update", post(categories_update)) .add("/admin/categories/{id}/delete", post(categories_delete)) .add("/admin/tags", get(tags_admin).post(tags_create)) diff --git a/backend/src/controllers/ai.rs b/backend/src/controllers/ai.rs new file mode 100644 index 0000000..d82ac24 --- /dev/null +++ b/backend/src/controllers/ai.rs @@ -0,0 +1,369 @@ +#![allow(clippy::unused_async)] + +use async_stream::stream; +use axum::{ + body::{Body, Bytes}, + http::{ + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + HeaderValue, + }, +}; +use chrono::{DateTime, Utc}; +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{controllers::admin::check_auth, services::ai}; + +#[derive(Clone, Debug, Deserialize)] +pub struct AskPayload { + pub question: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AskResponse { + pub question: String, + pub answer: String, + pub sources: Vec, + pub indexed_chunks: usize, + pub last_indexed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ReindexResponse { + pub indexed_chunks: usize, + pub last_indexed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamStatusEvent { + phase: String, + message: String, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamDeltaEvent { + delta: String, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamErrorEvent { + message: String, +} + +fn format_timestamp(value: Option>) -> Option { + value.map(|item| item.to_rfc3339()) +} + +fn sse_bytes(event: &str, payload: &T) -> Bytes { + let data = serde_json::to_string(payload).unwrap_or_else(|_| { + "{\"message\":\"failed to serialize SSE payload\"}".to_string() + }); + + Bytes::from(format!("event: {event}\ndata: {data}\n\n")) +} + +fn take_next_sse_event(buffer: &mut String) -> Option<(Option, String)> { + let mut boundary = buffer.find("\n\n").map(|index| (index, 2)); + if let Some(index) = buffer.find("\r\n\r\n") { + match boundary { + Some((existing, _)) if existing <= index => {} + _ => boundary = Some((index, 4)), + } + } + + let (index, separator_len) = boundary?; + let raw = buffer[..index].to_string(); + buffer.drain(..index + separator_len); + + let normalized = raw.replace("\r\n", "\n"); + let mut event = None; + let mut data_lines = Vec::new(); + + for line in normalized.lines() { + if let Some(value) = line.strip_prefix("event:") { + event = Some(value.trim().to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim_start().to_string()); + } + } + + Some((event, data_lines.join("\n"))) +} + +fn extract_stream_delta(value: &Value) -> Option { + if let Some(delta) = value.get("delta").and_then(Value::as_str) { + return Some(delta.to_string()); + } + + if let Some(content) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("delta")) + .and_then(|delta| delta.get("content")) + { + if let Some(text) = content.as_str() { + return Some(text.to_string()); + } + + if let Some(parts) = content.as_array() { + let merged = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(Value::as_str) + .or_else(|| part.as_str()) + }) + .collect::>() + .join(""); + + if !merged.is_empty() { + return Some(merged); + } + } + } + + value.get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("text")) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn append_missing_suffix(accumulated: &mut String, full_text: &str) -> Option { + if full_text.is_empty() { + return None; + } + + if accumulated.is_empty() { + accumulated.push_str(full_text); + return Some(full_text.to_string()); + } + + if full_text.starts_with(accumulated.as_str()) { + let suffix = full_text[accumulated.len()..].to_string(); + if !suffix.is_empty() { + accumulated.push_str(&suffix); + return Some(suffix); + } + } + + None +} + +fn chunk_text(value: &str, chunk_size: usize) -> Vec { + let chars = value.chars().collect::>(); + chars + .chunks(chunk_size.max(1)) + .map(|chunk| chunk.iter().collect::()) + .filter(|chunk| !chunk.is_empty()) + .collect::>() +} + +fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskResponse { + AskResponse { + question: prepared.question.clone(), + answer, + sources: prepared.sources.clone(), + indexed_chunks: prepared.indexed_chunks, + last_indexed_at: format_timestamp(prepared.last_indexed_at), + } +} + +#[debug_handler] +pub async fn ask( + State(ctx): State, + Json(payload): Json, +) -> Result { + let result = ai::answer_question(&ctx, &payload.question).await?; + format::json(AskResponse { + question: payload.question.trim().to_string(), + answer: result.answer, + sources: result.sources, + indexed_chunks: result.indexed_chunks, + last_indexed_at: format_timestamp(result.last_indexed_at), + }) +} + +#[debug_handler] +pub async fn ask_stream( + State(ctx): State, + Json(payload): Json, +) -> Result { + let stream = stream! { + yield Ok::(sse_bytes("status", &StreamStatusEvent { + phase: "retrieving".to_string(), + message: "正在检索知识库上下文...".to_string(), + })); + + let prepared = match ai::prepare_answer(&ctx, &payload.question).await { + Ok(prepared) => prepared, + Err(error) => { + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: error.to_string(), + })); + return; + } + }; + + let mut accumulated_answer = String::new(); + + if let Some(answer) = prepared.immediate_answer.as_deref() { + yield Ok(sse_bytes("status", &StreamStatusEvent { + phase: "answering".to_string(), + message: "已完成检索,正在输出检索结论...".to_string(), + })); + + for chunk in chunk_text(answer, 48) { + accumulated_answer.push_str(&chunk); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: chunk })); + } + } else if let Some(provider_request) = prepared.provider_request.as_ref() { + yield Ok(sse_bytes("status", &StreamStatusEvent { + phase: "streaming".to_string(), + message: "已命中相关资料,正在流式生成回答...".to_string(), + })); + + let client = reqwest::Client::new(); + let response = client + .post(ai::build_provider_url(provider_request)) + .bearer_auth(&provider_request.api_key) + .header("Accept", "text/event-stream, application/json") + .json(&ai::build_provider_payload(provider_request, true)) + .send() + .await; + + let mut response = match response { + Ok(response) => response, + Err(error) => { + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI request failed: {error}"), + })); + return; + } + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI provider returned {status}: {body}"), + })); + return; + } + + let mut sse_buffer = String::new(); + let mut last_full_answer = None; + + loop { + let next_chunk = response.chunk().await; + let Some(chunk) = (match next_chunk { + Ok(chunk) => chunk, + Err(error) => { + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI stream read failed: {error}"), + })); + return; + } + }) else { + break; + }; + + sse_buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some((_event_name, data)) = take_next_sse_event(&mut sse_buffer) { + let trimmed = data.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed == "[DONE]" { + continue; + } + + let parsed = match serde_json::from_str::(trimmed) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if let Some(full_text) = ai::extract_provider_text(&parsed) { + last_full_answer = Some(full_text); + } + + if let Some(delta) = extract_stream_delta(&parsed) { + accumulated_answer.push_str(&delta); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta })); + } + } + } + + let leftover = sse_buffer.trim(); + if !leftover.is_empty() && leftover != "[DONE]" { + if let Ok(parsed) = serde_json::from_str::(leftover) { + if let Some(full_text) = ai::extract_provider_text(&parsed) { + last_full_answer = Some(full_text); + } + + if let Some(delta) = extract_stream_delta(&parsed) { + accumulated_answer.push_str(&delta); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta })); + } + } + } + + if let Some(full_text) = last_full_answer { + if let Some(suffix) = append_missing_suffix(&mut accumulated_answer, &full_text) { + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: suffix })); + } + } + + if accumulated_answer.is_empty() { + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: "AI chat response did not contain readable content".to_string(), + })); + return; + } + } + + let final_payload = build_ask_response(&prepared, accumulated_answer); + yield Ok(sse_bytes("complete", &final_payload)); + }; + + let mut response = Response::new(Body::from_stream(stream)); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("keep-alive")); + + Ok(response) +} + +#[debug_handler] +pub async fn reindex(State(ctx): State) -> Result { + check_auth()?; + let summary = ai::rebuild_index(&ctx).await?; + + format::json(ReindexResponse { + indexed_chunks: summary.indexed_chunks, + last_indexed_at: format_timestamp(summary.last_indexed_at), + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/ai/") + .add("/ask", post(ask)) + .add("/ask/stream", post(ask_stream)) + .add("/reindex", post(reindex)) +} diff --git a/backend/src/controllers/category.rs b/backend/src/controllers/category.rs index 0d75f08..7e2a4ba 100644 --- a/backend/src/controllers/category.rs +++ b/backend/src/controllers/category.rs @@ -136,16 +136,32 @@ pub async fn update( let name = normalized_name(¶ms)?; let slug = normalized_slug(¶ms, &name); let item = load_item(&ctx, id).await?; + let previous_name = item.name.clone(); + let previous_slug = item.slug.clone(); + + if previous_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + != Some(name.as_str()) + { + content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?; + } + let mut item = item.into_active_model(); item.name = Set(Some(name)); item.slug = Set(slug); let item = item.update(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::json(item) } #[debug_handler] pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; + let item = load_item(&ctx, id).await?; + content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?; + item.delete(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::empty() } diff --git a/backend/src/controllers/comment.rs b/backend/src/controllers/comment.rs index 5440792..f2e357a 100644 --- a/backend/src/controllers/comment.rs +++ b/backend/src/controllers/comment.rs @@ -4,12 +4,16 @@ use loco_rs::prelude::*; use sea_orm::{ColumnTrait, QueryFilter, QueryOrder}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use crate::models::_entities::{ comments::{ActiveModel, Column, Entity, Model}, posts, }; +const ARTICLE_SCOPE: &str = "article"; +const PARAGRAPH_SCOPE: &str = "paragraph"; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Params { pub post_id: Option, @@ -19,6 +23,10 @@ pub struct Params { pub avatar: Option, pub content: Option, pub reply_to: Option, + pub reply_to_comment_id: Option, + pub scope: Option, + pub paragraph_key: Option, + pub paragraph_excerpt: Option, pub approved: Option, } @@ -45,6 +53,18 @@ impl Params { if let Some(reply_to) = self.reply_to { item.reply_to = Set(Some(reply_to)); } + if let Some(reply_to_comment_id) = self.reply_to_comment_id { + item.reply_to_comment_id = Set(Some(reply_to_comment_id)); + } + if let Some(scope) = &self.scope { + item.scope = Set(scope.clone()); + } + if let Some(paragraph_key) = &self.paragraph_key { + item.paragraph_key = Set(Some(paragraph_key.clone())); + } + if let Some(paragraph_excerpt) = &self.paragraph_excerpt { + item.paragraph_excerpt = Set(Some(paragraph_excerpt.clone())); + } if let Some(approved) = self.approved { item.approved = Set(Some(approved)); } @@ -55,6 +75,8 @@ impl Params { pub struct ListQuery { pub post_id: Option, pub post_slug: Option, + pub scope: Option, + pub paragraph_key: Option, pub approved: Option, } @@ -74,10 +96,58 @@ pub struct CreateCommentRequest { pub content: Option, #[serde(default, alias = "replyTo")] pub reply_to: Option, + #[serde(default, alias = "replyToCommentId")] + pub reply_to_comment_id: Option, + #[serde(default)] + pub scope: Option, + #[serde(default, alias = "paragraphKey")] + pub paragraph_key: Option, + #[serde(default, alias = "paragraphExcerpt")] + pub paragraph_excerpt: Option, #[serde(default)] pub approved: Option, } +#[derive(Clone, Debug, Serialize)] +pub struct ParagraphCommentSummary { + pub paragraph_key: String, + pub count: usize, +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalized_scope(value: Option) -> Result { + match value + .unwrap_or_else(|| ARTICLE_SCOPE.to_string()) + .trim() + .to_lowercase() + .as_str() + { + ARTICLE_SCOPE => Ok(ARTICLE_SCOPE.to_string()), + PARAGRAPH_SCOPE => Ok(PARAGRAPH_SCOPE.to_string()), + _ => Err(Error::BadRequest("invalid comment scope".to_string())), + } +} + +fn preview_excerpt(value: &str) -> Option { + let flattened = value.split_whitespace().collect::>().join(" "); + let excerpt = flattened.chars().take(120).collect::(); + if excerpt.is_empty() { + None + } else { + Some(excerpt) + } +} + async fn load_item(ctx: &AppContext, id: i32) -> Result { let item = Entity::find_by_id(id).one(&ctx.db).await?; item.ok_or_else(|| Error::NotFound) @@ -116,6 +186,19 @@ pub async fn list( db_query = db_query.filter(Column::PostSlug.eq(post_slug)); } + if let Some(scope) = query.scope { + db_query = db_query.filter(Column::Scope.eq(scope.trim().to_lowercase())); + } + + if let Some(paragraph_key) = query + .paragraph_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + db_query = db_query.filter(Column::ParagraphKey.eq(paragraph_key)); + } + if let Some(approved) = query.approved { db_query = db_query.filter(Column::Approved.eq(approved)); } @@ -123,18 +206,87 @@ pub async fn list( format::json(db_query.all(&ctx.db).await?) } +#[debug_handler] +pub async fn paragraph_summary( + Query(query): Query, + State(ctx): State, +) -> Result { + let post_slug = if let Some(post_slug) = query.post_slug { + Some(post_slug) + } else if let Some(post_id) = query.post_id { + resolve_post_slug(&ctx, &post_id).await? + } else { + None + } + .ok_or_else(|| Error::BadRequest("post_slug is required".to_string()))?; + + let items = Entity::find() + .filter(Column::PostSlug.eq(post_slug)) + .filter(Column::Scope.eq(PARAGRAPH_SCOPE)) + .filter(Column::Approved.eq(true)) + .order_by_asc(Column::CreatedAt) + .all(&ctx.db) + .await?; + + let mut counts = BTreeMap::::new(); + for item in items { + let Some(paragraph_key) = item.paragraph_key.as_deref() else { + continue; + }; + let key = paragraph_key.trim(); + if key.is_empty() { + continue; + } + + *counts.entry(key.to_string()).or_default() += 1; + } + + let summary = counts + .into_iter() + .map(|(paragraph_key, count)| ParagraphCommentSummary { paragraph_key, count }) + .collect::>(); + + format::json(summary) +} + #[debug_handler] pub async fn add( State(ctx): State, Json(params): Json, ) -> Result { + let scope = normalized_scope(params.scope.clone())?; let post_slug = if let Some(post_slug) = params.post_slug.as_deref() { Some(post_slug.to_string()) } else if let Some(post_id) = params.post_id.as_deref() { resolve_post_slug(&ctx, post_id).await? } else { None - }; + } + .and_then(|value| normalize_optional_string(Some(value))); + + let author = normalize_optional_string(params.author); + let email = normalize_optional_string(params.email); + let avatar = normalize_optional_string(params.avatar); + let content = normalize_optional_string(params.content); + let paragraph_key = normalize_optional_string(params.paragraph_key); + let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt) + .or_else(|| content.as_deref().and_then(preview_excerpt)); + + if post_slug.is_none() { + return Err(Error::BadRequest("post_slug is required".to_string())); + } + + if author.is_none() { + return Err(Error::BadRequest("author is required".to_string())); + } + + if content.is_none() { + return Err(Error::BadRequest("content is required".to_string())); + } + + if scope == PARAGRAPH_SCOPE && paragraph_key.is_none() { + return Err(Error::BadRequest("paragraph_key is required".to_string())); + } let mut item = ActiveModel { ..Default::default() @@ -144,14 +296,18 @@ pub async fn add( .as_deref() .and_then(|value| Uuid::parse_str(value).ok())); item.post_slug = Set(post_slug); - item.author = Set(params.author); - item.email = Set(params.email); - item.avatar = Set(params.avatar); - item.content = Set(params.content); + item.author = Set(author); + item.email = Set(email); + item.avatar = Set(avatar); + item.content = Set(content); + item.scope = Set(scope); + item.paragraph_key = Set(paragraph_key); + item.paragraph_excerpt = Set(paragraph_excerpt); item.reply_to = Set(params .reply_to .as_deref() .and_then(|value| Uuid::parse_str(value).ok())); + item.reply_to_comment_id = Set(params.reply_to_comment_id); item.approved = Set(Some(params.approved.unwrap_or(false))); let item = item.insert(&ctx.db).await?; format::json(item) @@ -185,6 +341,7 @@ pub fn routes() -> Routes { Routes::new() .prefix("api/comments/") .add("/", get(list)) + .add("paragraphs/summary", get(paragraph_summary)) .add("/", post(add)) .add("{id}", get(get_one)) .add("{id}", delete(remove)) diff --git a/backend/src/controllers/mod.rs b/backend/src/controllers/mod.rs index bf678f9..4a6c3b0 100644 --- a/backend/src/controllers/mod.rs +++ b/backend/src/controllers/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod ai; pub mod auth; pub mod category; pub mod comment; diff --git a/backend/src/controllers/post.rs b/backend/src/controllers/post.rs index c4767ff..2374dbd 100644 --- a/backend/src/controllers/post.rs +++ b/backend/src/controllers/post.rs @@ -51,6 +51,20 @@ pub struct MarkdownUpdateParams { pub markdown: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct MarkdownCreateParams { + pub title: String, + pub slug: Option, + pub description: Option, + pub content: Option, + pub category: Option, + pub tags: Option>, + pub post_type: Option, + pub image: Option, + pub pinned: Option, + pub published: Option, +} + #[derive(Clone, Debug, Serialize)] pub struct MarkdownDocumentResponse { pub slug: String, @@ -58,6 +72,12 @@ pub struct MarkdownDocumentResponse { pub markdown: String, } +#[derive(Clone, Debug, Serialize)] +pub struct MarkdownDeleteResponse { + pub slug: String, + pub deleted: bool, +} + async fn load_item(ctx: &AppContext, id: i32) -> Result { let item = Entity::find_by_id(id).one(&ctx.db).await?; item.ok_or_else(|| Error::NotFound) @@ -228,7 +248,11 @@ pub async fn get_markdown_by_slug( ) -> Result { content::sync_markdown_posts(&ctx).await?; let (path, markdown) = content::read_markdown_document(&slug)?; - format::json(MarkdownDocumentResponse { slug, path, markdown }) + format::json(MarkdownDocumentResponse { + slug, + path, + markdown, + }) } #[debug_handler] @@ -247,14 +271,64 @@ pub async fn update_markdown_by_slug( }) } +#[debug_handler] +pub async fn create_markdown( + State(ctx): State, + Json(params): Json, +) -> Result { + let title = params.title.trim(); + if title.is_empty() { + return Err(Error::BadRequest("title is required".to_string())); + } + + let default_body = format!("# {title}\n"); + let created = content::create_markdown_post( + &ctx, + content::MarkdownPostDraft { + title: title.to_string(), + slug: params.slug, + description: params.description, + content: params.content.unwrap_or(default_body), + category: params.category, + tags: params.tags.unwrap_or_default(), + post_type: params.post_type.unwrap_or_else(|| "article".to_string()), + image: params.image, + pinned: params.pinned.unwrap_or(false), + published: params.published.unwrap_or(true), + }, + ) + .await?; + let (path, markdown) = content::read_markdown_document(&created.slug)?; + + format::json(MarkdownDocumentResponse { + slug: created.slug, + path, + markdown, + }) +} + +#[debug_handler] +pub async fn delete_markdown_by_slug( + Path(slug): Path, + State(ctx): State, +) -> Result { + content::delete_markdown_post(&ctx, &slug).await?; + format::json(MarkdownDeleteResponse { + slug, + deleted: true, + }) +} + pub fn routes() -> Routes { Routes::new() .prefix("api/posts/") .add("/", get(list)) .add("/", post(add)) + .add("markdown", post(create_markdown)) .add("slug/{slug}/markdown", get(get_markdown_by_slug)) .add("slug/{slug}/markdown", put(update_markdown_by_slug)) .add("slug/{slug}/markdown", patch(update_markdown_by_slug)) + .add("slug/{slug}/markdown", delete(delete_markdown_by_slug)) .add("slug/{slug}", get(get_by_slug)) .add("{id}", get(get_one)) .add("{id}", delete(remove)) diff --git a/backend/src/controllers/search.rs b/backend/src/controllers/search.rs index 4322bf1..c608509 100644 --- a/backend/src/controllers/search.rs +++ b/backend/src/controllers/search.rs @@ -174,7 +174,10 @@ pub async fn search( [q.clone().into(), (limit as i64).into()], ); - match SearchResult::find_by_statement(statement).all(&ctx.db).await { + match SearchResult::find_by_statement(statement) + .all(&ctx.db) + .await + { Ok(rows) => rows, Err(_) => fallback_search(&ctx, &q, limit).await?, } diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs index cb850af..76e2e3a 100644 --- a/backend/src/controllers/site_settings.rs +++ b/backend/src/controllers/site_settings.rs @@ -6,7 +6,11 @@ use loco_rs::prelude::*; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; use serde::{Deserialize, Serialize}; -use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model}; +use crate::{ + controllers::admin::check_auth, + models::_entities::site_settings::{self, ActiveModel, Entity, Model}, + services::ai, +}; #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SiteSettingsPayload { @@ -42,6 +46,46 @@ pub struct SiteSettingsPayload { pub location: Option, #[serde(default, alias = "techStack")] pub tech_stack: Option>, + #[serde(default, alias = "aiEnabled")] + pub ai_enabled: Option, + #[serde(default, alias = "aiProvider")] + pub ai_provider: Option, + #[serde(default, alias = "aiApiBase")] + pub ai_api_base: Option, + #[serde(default, alias = "aiApiKey")] + pub ai_api_key: Option, + #[serde(default, alias = "aiChatModel")] + pub ai_chat_model: Option, + #[serde(default, alias = "aiEmbeddingModel")] + pub ai_embedding_model: Option, + #[serde(default, alias = "aiSystemPrompt")] + pub ai_system_prompt: Option, + #[serde(default, alias = "aiTopK")] + pub ai_top_k: Option, + #[serde(default, alias = "aiChunkSize")] + pub ai_chunk_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicSiteSettingsResponse { + pub id: i32, + pub site_name: Option, + pub site_short_name: Option, + pub site_url: Option, + pub site_title: Option, + pub site_description: Option, + pub hero_title: Option, + pub hero_subtitle: Option, + pub owner_name: Option, + pub owner_title: Option, + pub owner_bio: Option, + pub owner_avatar_url: Option, + pub social_github: Option, + pub social_twitter: Option, + pub social_email: Option, + pub location: Option, + pub tech_stack: Option, + pub ai_enabled: bool, } fn normalize_optional_string(value: Option) -> Option { @@ -55,6 +99,10 @@ fn normalize_optional_string(value: Option) -> Option { }) } +fn normalize_optional_int(value: Option, min: i32, max: i32) -> Option { + value.map(|item| item.clamp(min, max)) +} + impl SiteSettingsPayload { fn apply(self, item: &mut ActiveModel) { if let Some(site_name) = self.site_name { @@ -105,6 +153,33 @@ impl SiteSettingsPayload { if let Some(tech_stack) = self.tech_stack { item.tech_stack = Set(Some(serde_json::json!(tech_stack))); } + if let Some(ai_enabled) = self.ai_enabled { + item.ai_enabled = Set(Some(ai_enabled)); + } + if let Some(ai_provider) = self.ai_provider { + item.ai_provider = Set(normalize_optional_string(Some(ai_provider))); + } + if let Some(ai_api_base) = self.ai_api_base { + item.ai_api_base = Set(normalize_optional_string(Some(ai_api_base))); + } + if let Some(ai_api_key) = self.ai_api_key { + item.ai_api_key = Set(normalize_optional_string(Some(ai_api_key))); + } + if let Some(ai_chat_model) = self.ai_chat_model { + item.ai_chat_model = Set(normalize_optional_string(Some(ai_chat_model))); + } + if let Some(ai_embedding_model) = self.ai_embedding_model { + item.ai_embedding_model = Set(normalize_optional_string(Some(ai_embedding_model))); + } + if let Some(ai_system_prompt) = self.ai_system_prompt { + item.ai_system_prompt = Set(normalize_optional_string(Some(ai_system_prompt))); + } + if self.ai_top_k.is_some() { + item.ai_top_k = Set(normalize_optional_int(self.ai_top_k, 1, 12)); + } + if self.ai_chunk_size.is_some() { + item.ai_chunk_size = Set(normalize_optional_int(self.ai_chunk_size, 400, 4000)); + } } } @@ -134,10 +209,22 @@ fn default_payload() -> SiteSettingsPayload { "Tailwind CSS".to_string(), "TypeScript".to_string(), ]), + ai_enabled: Some(false), + ai_provider: Some(ai::provider_name(None)), + ai_api_base: Some(ai::default_api_base().to_string()), + ai_api_key: Some(ai::default_api_key().to_string()), + ai_chat_model: Some(ai::default_chat_model().to_string()), + ai_embedding_model: Some(ai::local_embedding_label().to_string()), + ai_system_prompt: Some( + "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。" + .to_string(), + ), + ai_top_k: Some(4), + ai_chunk_size: Some(1200), } } -async fn load_current(ctx: &AppContext) -> Result { +pub(crate) async fn load_current(ctx: &AppContext) -> Result { if let Some(settings) = Entity::find() .order_by_asc(site_settings::Column::Id) .one(&ctx.db) @@ -154,9 +241,32 @@ async fn load_current(ctx: &AppContext) -> Result { Ok(item.insert(&ctx.db).await?) } +fn public_response(model: Model) -> PublicSiteSettingsResponse { + PublicSiteSettingsResponse { + id: model.id, + site_name: model.site_name, + site_short_name: model.site_short_name, + site_url: model.site_url, + site_title: model.site_title, + site_description: model.site_description, + hero_title: model.hero_title, + hero_subtitle: model.hero_subtitle, + owner_name: model.owner_name, + owner_title: model.owner_title, + owner_bio: model.owner_bio, + owner_avatar_url: model.owner_avatar_url, + social_github: model.social_github, + social_twitter: model.social_twitter, + social_email: model.social_email, + location: model.location, + tech_stack: model.tech_stack, + ai_enabled: model.ai_enabled.unwrap_or(false), + } +} + #[debug_handler] pub async fn show(State(ctx): State) -> Result { - format::json(load_current(&ctx).await?) + format::json(public_response(load_current(&ctx).await?)) } #[debug_handler] @@ -164,10 +274,13 @@ pub async fn update( State(ctx): State, Json(params): Json, ) -> Result { + check_auth()?; + let current = load_current(&ctx).await?; let mut item = current.into_active_model(); params.apply(&mut item); - format::json(item.update(&ctx.db).await?) + let updated = item.update(&ctx.db).await?; + format::json(public_response(updated)) } pub fn routes() -> Routes { diff --git a/backend/src/controllers/tag.rs b/backend/src/controllers/tag.rs index 1e75473..575bb7f 100644 --- a/backend/src/controllers/tag.rs +++ b/backend/src/controllers/tag.rs @@ -48,15 +48,38 @@ pub async fn update( Json(params): Json, ) -> Result { let item = load_item(&ctx, id).await?; + let previous_name = item.name.clone(); + let previous_slug = item.slug.clone(); + let next_name = params + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(next_name) = next_name { + if previous_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + != Some(next_name) + { + content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(next_name))?; + } + } + let mut item = item.into_active_model(); params.update(&mut item); let item = item.update(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::json(item) } #[debug_handler] pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; + let item = load_item(&ctx, id).await?; + content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?; + item.delete(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::empty() } diff --git a/backend/src/fixtures/site_settings.yaml b/backend/src/fixtures/site_settings.yaml index 4f9adbd..fd16577 100644 --- a/backend/src/fixtures/site_settings.yaml +++ b/backend/src/fixtures/site_settings.yaml @@ -19,3 +19,12 @@ - "Svelte" - "Tailwind CSS" - "TypeScript" + ai_enabled: false + ai_provider: "newapi" + ai_api_base: "http://localhost:8317/v1" + ai_api_key: "your-api-key-1" + ai_chat_model: "gpt-5.4" + ai_embedding_model: "fastembed / local all-MiniLM-L6-v2" + ai_system_prompt: "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。" + ai_top_k: 4 + ai_chunk_size: 1200 diff --git a/backend/src/initializers/content_sync.rs b/backend/src/initializers/content_sync.rs index 1be52c0..4e27754 100644 --- a/backend/src/initializers/content_sync.rs +++ b/backend/src/initializers/content_sync.rs @@ -56,6 +56,13 @@ fn is_blank(value: &Option) -> bool { value.as_deref().map(str::trim).unwrap_or("").is_empty() } +fn matches_legacy_ai_defaults(settings: &site_settings::Model) -> bool { + settings.ai_provider.as_deref().map(str::trim) == Some("openai-compatible") + && settings.ai_api_base.as_deref().map(str::trim) == Some("https://api.openai.com/v1") + && settings.ai_chat_model.as_deref().map(str::trim) == Some("gpt-4.1-mini") + && is_blank(&settings.ai_api_key) +} + async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { let rows = read_fixture_rows(base, "site_settings.yaml"); let Some(seed) = rows.first() else { @@ -81,6 +88,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if let Some(existing) = existing { let mut model = existing.clone().into_active_model(); + let should_upgrade_legacy_ai_defaults = matches_legacy_ai_defaults(&existing); if is_blank(&existing.site_name) { model.site_name = Set(as_optional_string(&seed["site_name"])); @@ -130,6 +138,39 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if existing.tech_stack.is_none() { model.tech_stack = Set(tech_stack); } + if existing.ai_enabled.is_none() { + model.ai_enabled = Set(seed["ai_enabled"].as_bool()); + } + if should_upgrade_legacy_ai_defaults { + model.ai_provider = Set(as_optional_string(&seed["ai_provider"])); + model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"])); + model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"])); + model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"])); + } + if is_blank(&existing.ai_provider) { + model.ai_provider = Set(as_optional_string(&seed["ai_provider"])); + } + if is_blank(&existing.ai_api_base) { + model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"])); + } + if is_blank(&existing.ai_api_key) { + model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"])); + } + if is_blank(&existing.ai_chat_model) { + model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"])); + } + if is_blank(&existing.ai_embedding_model) { + model.ai_embedding_model = Set(as_optional_string(&seed["ai_embedding_model"])); + } + if is_blank(&existing.ai_system_prompt) { + model.ai_system_prompt = Set(as_optional_string(&seed["ai_system_prompt"])); + } + if existing.ai_top_k.is_none() { + model.ai_top_k = Set(seed["ai_top_k"].as_i64().map(|value| value as i32)); + } + if existing.ai_chunk_size.is_none() { + model.ai_chunk_size = Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)); + } let _ = model.update(&ctx.db).await; return Ok(()); @@ -153,6 +194,15 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { social_email: Set(as_optional_string(&seed["social_email"])), location: Set(as_optional_string(&seed["location"])), tech_stack: Set(tech_stack), + ai_enabled: Set(seed["ai_enabled"].as_bool()), + ai_provider: Set(as_optional_string(&seed["ai_provider"])), + ai_api_base: Set(as_optional_string(&seed["ai_api_base"])), + ai_api_key: Set(as_optional_string(&seed["ai_api_key"])), + ai_chat_model: Set(as_optional_string(&seed["ai_chat_model"])), + ai_embedding_model: Set(as_optional_string(&seed["ai_embedding_model"])), + ai_system_prompt: Set(as_optional_string(&seed["ai_system_prompt"])), + ai_top_k: Set(seed["ai_top_k"].as_i64().map(|value| value as i32)), + ai_chunk_size: Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)), ..Default::default() }; diff --git a/backend/src/models/_entities/ai_chunks.rs b/backend/src/models/_entities/ai_chunks.rs new file mode 100644 index 0000000..9c0846a --- /dev/null +++ b/backend/src/models/_entities/ai_chunks.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ai_chunks")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub source_slug: String, + pub source_title: Option, + pub source_path: Option, + pub source_type: String, + pub chunk_index: i32, + #[sea_orm(column_type = "Text")] + pub content: String, + pub content_preview: Option, + pub embedding: Option, + pub word_count: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/comments.rs b/backend/src/models/_entities/comments.rs index 6ee8da7..fd3f8b6 100644 --- a/backend/src/models/_entities/comments.rs +++ b/backend/src/models/_entities/comments.rs @@ -17,7 +17,11 @@ pub struct Model { pub avatar: Option, #[sea_orm(column_type = "Text", nullable)] pub content: Option, + pub scope: String, + pub paragraph_key: Option, + pub paragraph_excerpt: Option, pub reply_to: Option, + pub reply_to_comment_id: Option, pub approved: Option, } diff --git a/backend/src/models/_entities/mod.rs b/backend/src/models/_entities/mod.rs index 70bcd16..77fd8ae 100644 --- a/backend/src/models/_entities/mod.rs +++ b/backend/src/models/_entities/mod.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 +pub mod ai_chunks; pub mod prelude; pub mod categories; diff --git a/backend/src/models/_entities/prelude.rs b/backend/src/models/_entities/prelude.rs index a6dfc59..3f86e17 100644 --- a/backend/src/models/_entities/prelude.rs +++ b/backend/src/models/_entities/prelude.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 +pub use super::ai_chunks::Entity as AiChunks; pub use super::categories::Entity as Categories; pub use super::comments::Entity as Comments; pub use super::friend_links::Entity as FriendLinks; diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs index 4795054..0e3cb3e 100644 --- a/backend/src/models/_entities/site_settings.rs +++ b/backend/src/models/_entities/site_settings.rs @@ -28,6 +28,18 @@ pub struct Model { pub location: Option, #[sea_orm(column_type = "JsonBinary", nullable)] pub tech_stack: Option, + pub ai_enabled: Option, + pub ai_provider: Option, + pub ai_api_base: Option, + #[sea_orm(column_type = "Text", nullable)] + pub ai_api_key: Option, + pub ai_chat_model: Option, + pub ai_embedding_model: Option, + #[sea_orm(column_type = "Text", nullable)] + pub ai_system_prompt: Option, + pub ai_top_k: Option, + pub ai_chunk_size: Option, + pub ai_last_indexed_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/backend/src/models/ai_chunks.rs b/backend/src/models/ai_chunks.rs new file mode 100644 index 0000000..bdfe305 --- /dev/null +++ b/backend/src/models/ai_chunks.rs @@ -0,0 +1,3 @@ +pub use super::_entities::ai_chunks::{ActiveModel, Entity, Model}; + +pub type AiChunks = Entity; diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bf149a6..d7659d4 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod _entities; +pub mod ai_chunks; pub mod categories; pub mod comments; pub mod friend_links; diff --git a/backend/src/services/ai.rs b/backend/src/services/ai.rs new file mode 100644 index 0000000..d9b82b4 --- /dev/null +++ b/backend/src/services/ai.rs @@ -0,0 +1,993 @@ +use chrono::{DateTime, Utc}; +use fastembed::{ + InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel, +}; +use loco_rs::prelude::*; +use reqwest::Client; +use sea_orm::{ + ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel, + PaginatorTrait, QueryOrder, Set, Statement, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use crate::{ + models::_entities::{ai_chunks, site_settings}, + services::content, +}; + +const DEFAULT_AI_PROVIDER: &str = "newapi"; +const DEFAULT_AI_API_BASE: &str = "http://localhost:8317/v1"; +const DEFAULT_AI_API_KEY: &str = "your-api-key-1"; +const DEFAULT_CHAT_MODEL: &str = "gpt-5.4"; +const DEFAULT_REASONING_EFFORT: &str = "medium"; +const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true; +const DEFAULT_TOP_K: usize = 4; +const DEFAULT_CHUNK_SIZE: usize = 1200; +const DEFAULT_SYSTEM_PROMPT: &str = + "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。"; +const EMBEDDING_BATCH_SIZE: usize = 32; +const EMBEDDING_DIMENSION: usize = 384; +const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2"; +const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2"; +const LOCAL_EMBEDDING_BASE_URL: &str = + "https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main"; +const LOCAL_EMBEDDING_FILES: [&str; 5] = [ + "model.onnx", + "tokenizer.json", + "config.json", + "special_tokens_map.json", + "tokenizer_config.json", +]; + +static TEXT_EMBEDDING_MODEL: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +struct AiRuntimeSettings { + raw: site_settings::Model, + provider: String, + api_base: Option, + api_key: Option, + chat_model: String, + system_prompt: String, + top_k: usize, + chunk_size: usize, +} + +#[derive(Clone, Debug)] +struct ChunkDraft { + source_slug: String, + source_title: Option, + source_path: Option, + source_type: String, + chunk_index: i32, + content: String, + content_preview: Option, + word_count: Option, +} + +#[derive(Clone, Debug)] +struct ScoredChunk { + score: f64, + row: ai_chunks::Model, +} + +#[derive(Clone, Debug, FromQueryResult)] +struct SimilarChunkRow { + source_slug: String, + source_title: Option, + chunk_index: i32, + content: String, + content_preview: Option, + word_count: Option, + score: f64, +} + +#[derive(Clone, Copy, Debug)] +enum EmbeddingKind { + Passage, + Query, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AiSource { + pub slug: String, + pub title: String, + pub excerpt: String, + pub score: f64, + pub chunk_index: i32, +} + +#[derive(Clone, Debug)] +pub struct AiAnswer { + pub answer: String, + pub sources: Vec, + pub indexed_chunks: usize, + pub last_indexed_at: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct AiProviderRequest { + pub(crate) provider: String, + pub(crate) api_base: String, + pub(crate) api_key: String, + pub(crate) chat_model: String, + pub(crate) system_prompt: String, + pub(crate) prompt: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct PreparedAiAnswer { + pub(crate) question: String, + pub(crate) provider_request: Option, + pub(crate) immediate_answer: Option, + pub(crate) sources: Vec, + pub(crate) indexed_chunks: usize, + pub(crate) last_indexed_at: Option>, +} + +#[derive(Clone, Debug)] +pub struct AiIndexSummary { + pub indexed_chunks: usize, + pub last_indexed_at: Option>, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn preview_text(content: &str, limit: usize) -> Option { + let flattened = content + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string(); + + if flattened.is_empty() { + return None; + } + + let preview = flattened.chars().take(limit).collect::(); + Some(preview) +} + +fn build_endpoint(api_base: &str, path: &str) -> String { + format!( + "{}/{}", + api_base.trim_end_matches('/'), + path.trim_start_matches('/') + ) +} + +fn local_embedding_dir() -> PathBuf { + PathBuf::from(LOCAL_EMBEDDING_CACHE_DIR) +} + +fn download_embedding_file( + client: &reqwest::blocking::Client, + directory: &Path, + file_name: &str, +) -> Result<()> { + let target_path = directory.join(file_name); + if target_path.exists() { + return Ok(()); + } + + let url = format!("{LOCAL_EMBEDDING_BASE_URL}/{file_name}"); + let bytes = client + .get(url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| Error::BadRequest(format!("下载本地 embedding 文件失败: {error}")))? + .bytes() + .map_err(|error| Error::BadRequest(format!("读取本地 embedding 文件失败: {error}")))?; + + fs::write(&target_path, &bytes) + .map_err(|error| Error::BadRequest(format!("写入本地 embedding 文件失败: {error}")))?; + + Ok(()) +} + +fn ensure_local_embedding_files() -> Result { + let directory = local_embedding_dir(); + fs::create_dir_all(&directory) + .map_err(|error| Error::BadRequest(format!("创建本地 embedding 目录失败: {error}")))?; + + let client = reqwest::blocking::Client::builder() + .build() + .map_err(|error| { + Error::BadRequest(format!("创建本地 embedding 下载客户端失败: {error}")) + })?; + + for file_name in LOCAL_EMBEDDING_FILES { + download_embedding_file(&client, &directory, file_name)?; + } + + Ok(directory) +} + +fn load_local_embedding_model() -> Result { + let directory = ensure_local_embedding_files()?; + let tokenizer_files = TokenizerFiles { + tokenizer_file: fs::read(directory.join("tokenizer.json")) + .map_err(|error| Error::BadRequest(format!("读取 tokenizer.json 失败: {error}")))?, + config_file: fs::read(directory.join("config.json")) + .map_err(|error| Error::BadRequest(format!("读取 config.json 失败: {error}")))?, + special_tokens_map_file: fs::read(directory.join("special_tokens_map.json")).map_err( + |error| Error::BadRequest(format!("读取 special_tokens_map.json 失败: {error}")), + )?, + tokenizer_config_file: fs::read(directory.join("tokenizer_config.json")).map_err( + |error| Error::BadRequest(format!("读取 tokenizer_config.json 失败: {error}")), + )?, + }; + + let model = UserDefinedEmbeddingModel::new( + fs::read(directory.join("model.onnx")) + .map_err(|error| Error::BadRequest(format!("读取 model.onnx 失败: {error}")))?, + tokenizer_files, + ) + .with_pooling(Pooling::Mean); + + TextEmbedding::try_new_from_user_defined(model, InitOptionsUserDefined::default()) + .map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}"))) +} + +fn local_embedding_engine() -> Result<&'static Mutex> { + if let Some(model) = TEXT_EMBEDDING_MODEL.get() { + return Ok(model); + } + + let model = load_local_embedding_model()?; + + let _ = TEXT_EMBEDDING_MODEL.set(Mutex::new(model)); + + TEXT_EMBEDDING_MODEL + .get() + .ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string())) +} + +fn vector_literal(embedding: &[f64]) -> Result { + if embedding.len() != EMBEDDING_DIMENSION { + return Err(Error::BadRequest(format!( + "embedding 维度异常,期望 {EMBEDDING_DIMENSION},实际 {}", + embedding.len() + ))); + } + + Ok(format!( + "[{}]", + embedding + .iter() + .map(|value| value.to_string()) + .collect::>() + .join(",") + )) +} + +fn prepare_embedding_text(kind: EmbeddingKind, text: &str) -> String { + match kind { + EmbeddingKind::Passage | EmbeddingKind::Query => text.trim().to_string(), + } +} + +fn split_long_text(text: &str, chunk_size: usize) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + + for line in text.lines() { + let candidate = if current.is_empty() { + line.to_string() + } else { + format!("{current}\n{line}") + }; + + if candidate.chars().count() > chunk_size && !current.is_empty() { + parts.push(current.trim().to_string()); + current = line.to_string(); + } else { + current = candidate; + } + } + + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + + parts +} + +fn build_chunks(posts: &[content::MarkdownPost], chunk_size: usize) -> Vec { + let mut chunks = Vec::new(); + + for post in posts.iter().filter(|post| post.published) { + let mut sections = Vec::new(); + sections.push(format!("# {}", post.title)); + if let Some(description) = post + .description + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + sections.push(description.trim().to_string()); + } + sections.push(post.content.trim().to_string()); + + let source_text = sections + .into_iter() + .filter(|item| !item.trim().is_empty()) + .collect::>() + .join("\n\n"); + + let paragraphs = source_text + .split("\n\n") + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + + let mut buffer = String::new(); + let mut chunk_index = 0_i32; + + for paragraph in paragraphs { + if paragraph.chars().count() > chunk_size { + if !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug: post.slug.clone(), + source_title: Some(post.title.clone()), + source_path: Some(post.file_path.clone()), + source_type: "post".to_string(), + chunk_index, + content: buffer.trim().to_string(), + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + }); + chunk_index += 1; + buffer.clear(); + } + + for part in split_long_text(paragraph, chunk_size) { + if part.trim().is_empty() { + continue; + } + + chunks.push(ChunkDraft { + source_slug: post.slug.clone(), + source_title: Some(post.title.clone()), + source_path: Some(post.file_path.clone()), + source_type: "post".to_string(), + chunk_index, + content_preview: preview_text(&part, 180), + word_count: Some(part.split_whitespace().count() as i32), + content: part, + }); + chunk_index += 1; + } + continue; + } + + let candidate = if buffer.is_empty() { + paragraph.to_string() + } else { + format!("{buffer}\n\n{paragraph}") + }; + + if candidate.chars().count() > chunk_size && !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug: post.slug.clone(), + source_title: Some(post.title.clone()), + source_path: Some(post.file_path.clone()), + source_type: "post".to_string(), + chunk_index, + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + content: buffer.trim().to_string(), + }); + chunk_index += 1; + buffer = paragraph.to_string(); + } else { + buffer = candidate; + } + } + + if !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug: post.slug.clone(), + source_title: Some(post.title.clone()), + source_path: Some(post.file_path.clone()), + source_type: "post".to_string(), + chunk_index, + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + content: buffer.trim().to_string(), + }); + } + } + + chunks +} + +async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result { + let response = client + .post(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .json(&payload) + .send() + .await + .map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?; + + if !status.is_success() { + return Err(Error::BadRequest(format!( + "AI provider returned {status}: {body}" + ))); + } + + serde_json::from_str(&body) + .map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}"))) +} + +fn provider_uses_responses(provider: &str) -> bool { + provider.eq_ignore_ascii_case("newapi") +} + +async fn embed_texts_locally(inputs: Vec, kind: EmbeddingKind) -> Result>> { + tokio::task::spawn_blocking(move || { + let model = local_embedding_engine()?; + let prepared = inputs + .iter() + .map(|item| prepare_embedding_text(kind, item)) + .collect::>(); + + let mut guard = model.lock().map_err(|_| { + Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string()) + })?; + + let embeddings = guard + .embed(prepared, Some(EMBEDDING_BATCH_SIZE)) + .map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?; + + Ok(embeddings + .into_iter() + .map(|embedding| embedding.into_iter().map(f64::from).collect::>()) + .collect::>()) + }) + .await + .map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))? +} + +fn extract_message_content(value: &Value) -> Option { + if let Some(content) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + { + if let Some(text) = content.as_str() { + return Some(text.trim().to_string()); + } + + if let Some(parts) = content.as_array() { + let merged = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + + if !merged.trim().is_empty() { + return Some(merged.trim().to_string()); + } + } + } + + None +} + +fn merge_text_segments(parts: Vec) -> Option { + let merged = parts + .into_iter() + .filter_map(|part| { + let trimmed = part.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) + .collect::>() + .join("\n"); + + if merged.trim().is_empty() { + None + } else { + Some(merged) + } +} + +fn extract_response_output(value: &Value) -> Option { + if let Some(text) = value.get("output_text").and_then(Value::as_str) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + let output_items = value.get("output").and_then(Value::as_array)?; + let mut segments = Vec::new(); + + for item in output_items { + let Some(content_items) = item.get("content").and_then(Value::as_array) else { + continue; + }; + + for content in content_items { + if let Some(text) = content.get("text").and_then(Value::as_str) { + segments.push(text.to_string()); + continue; + } + + if let Some(text) = content + .get("output_text") + .and_then(|output_text| output_text.get("text")) + .and_then(Value::as_str) + { + segments.push(text.to_string()); + } + } + } + + merge_text_segments(segments) +} + +fn build_chat_prompt(question: &str, matches: &[ScoredChunk]) -> String { + let context_blocks = matches + .iter() + .enumerate() + .map(|(index, item)| { + format!( + "[资料 {}]\n标题: {}\nSlug: {}\n相似度: {:.4}\n内容:\n{}", + index + 1, + item.row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容"), + item.row.source_slug, + item.score, + item.row.content + ) + }) + .collect::>() + .join("\n\n"); + + format!( + "请仅根据下面提供的资料回答用户问题。\n\ +如果资料不足以支撑结论,请直接说明“我在当前博客资料里没有找到足够信息”。\n\ +回答要求:\n\ +1. 使用中文。\n\ +2. 使用 Markdown 输出,必要时用短列表或小标题,不要输出 HTML。\n\ +3. 先给直接结论,再补充关键点,整体尽量精炼。\n\ +4. 不要编造未在资料中出现的事实。\n\ +5. 如果回答引用了具体资料,可自然地提及文章标题。\n\n\ +用户问题:{question}\n\n\ +可用资料:\n{context_blocks}" + ) +} + +fn build_sources(matches: &[ScoredChunk]) -> Vec { + matches + .iter() + .map(|item| AiSource { + slug: item.row.source_slug.clone(), + title: item + .row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容") + .to_string(), + excerpt: item + .row + .content_preview + .clone() + .unwrap_or_else(|| preview_text(&item.row.content, 180).unwrap_or_default()), + score: (item.score * 10000.0).round() / 10000.0, + chunk_index: item.row.chunk_index, + }) + .collect::>() +} + +pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value { + if provider_uses_responses(&request.provider) { + json!({ + "model": request.chat_model, + "input": [ + { + "role": "system", + "content": [ + { + "type": "input_text", + "text": request.system_prompt + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": request.prompt + } + ] + } + ], + "reasoning": { + "effort": DEFAULT_REASONING_EFFORT + }, + "max_output_tokens": 520, + "store": !DEFAULT_DISABLE_RESPONSE_STORAGE, + "stream": stream + }) + } else { + json!({ + "model": request.chat_model, + "temperature": 0.2, + "stream": stream, + "messages": [ + { + "role": "system", + "content": request.system_prompt, + }, + { + "role": "user", + "content": request.prompt, + } + ] + }) + } +} + +pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String { + let path = if provider_uses_responses(&request.provider) { + "/responses" + } else { + "/chat/completions" + }; + + build_endpoint(&request.api_base, path) +} + +pub(crate) fn extract_provider_text(value: &Value) -> Option { + extract_response_output(value).or_else(|| extract_message_content(value)) +} + +async fn request_chat_answer(request: &AiProviderRequest) -> Result { + let client = Client::new(); + let response = request_json( + &client, + &build_provider_url(request), + &request.api_key, + build_provider_payload(request, false), + ) + .await?; + + extract_provider_text(&response).ok_or_else(|| { + Error::BadRequest("AI chat response did not contain readable content".to_string()) + }) +} + +pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result { + let trimmed_question = question.trim(); + if trimmed_question.is_empty() { + return Err(Error::BadRequest("问题不能为空".to_string())); + } + + let settings = load_runtime_settings(ctx, true).await?; + let (matches, indexed_chunks, last_indexed_at) = + retrieve_matches(ctx, &settings, trimmed_question).await?; + + if matches.is_empty() { + return Ok(PreparedAiAnswer { + question: trimmed_question.to_string(), + provider_request: None, + immediate_answer: Some( + "我在当前博客资料里没有找到足够信息。你可以换个更具体的问题,或者先去后台重建一下 AI 索引。" + .to_string(), + ), + sources: Vec::new(), + indexed_chunks, + last_indexed_at, + }); + } + + let sources = build_sources(&matches); + let provider_request = match (settings.api_base.clone(), settings.api_key.clone()) { + (Some(api_base), Some(api_key)) => Some(AiProviderRequest { + provider: settings.provider.clone(), + api_base, + api_key, + chat_model: settings.chat_model.clone(), + system_prompt: settings.system_prompt.clone(), + prompt: build_chat_prompt(trimmed_question, &matches), + }), + _ => None, + }; + + let immediate_answer = provider_request + .is_none() + .then(|| retrieval_only_answer(&matches)); + + Ok(PreparedAiAnswer { + question: trimmed_question.to_string(), + provider_request, + immediate_answer, + sources, + indexed_chunks, + last_indexed_at, + }) +} + +fn retrieval_only_answer(matches: &[ScoredChunk]) -> String { + let summary = matches + .iter() + .take(3) + .map(|item| { + let title = item + .row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容"); + let excerpt = item + .row + .content_preview + .clone() + .unwrap_or_else(|| preview_text(&item.row.content, 120).unwrap_or_default()); + + format!("《{title}》: {excerpt}") + }) + .collect::>() + .join("\n"); + + format!( + "本地知识检索已经完成,但后台还没有配置聊天模型 API,所以我先返回最相关的资料摘要:\n{summary}\n\n\ +如果你希望得到完整的自然语言回答,请在后台补上聊天模型的 API Base / API Key。" + ) +} + +async fn load_runtime_settings( + ctx: &AppContext, + require_enabled: bool, +) -> Result { + let raw = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + if require_enabled && !raw.ai_enabled.unwrap_or(false) { + return Err(Error::NotFound); + } + + Ok(AiRuntimeSettings { + provider: provider_name(raw.ai_provider.as_deref()), + api_base: trim_to_option(raw.ai_api_base.clone()), + api_key: trim_to_option(raw.ai_api_key.clone()), + chat_model: trim_to_option(raw.ai_chat_model.clone()) + .unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()), + system_prompt: trim_to_option(raw.ai_system_prompt.clone()) + .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()), + top_k: raw + .ai_top_k + .map(|value| value.clamp(1, 12) as usize) + .unwrap_or(DEFAULT_TOP_K), + chunk_size: raw + .ai_chunk_size + .map(|value| value.clamp(400, 4000) as usize) + .unwrap_or(DEFAULT_CHUNK_SIZE), + raw, + }) +} + +async fn update_indexed_at( + ctx: &AppContext, + settings: &site_settings::Model, +) -> Result> { + let now = Utc::now(); + let mut model = settings.clone().into_active_model(); + model.ai_last_indexed_at = Set(Some(now.into())); + let _ = model.update(&ctx.db).await?; + Ok(now) +} + +async fn retrieve_matches( + ctx: &AppContext, + settings: &AiRuntimeSettings, + question: &str, +) -> Result<(Vec, usize, Option>)> { + let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize; + let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into); + + if indexed_chunks == 0 { + let summary = rebuild_index(ctx).await?; + indexed_chunks = summary.indexed_chunks; + last_indexed_at = summary.last_indexed_at; + } + + if indexed_chunks == 0 { + return Ok((Vec::new(), 0, last_indexed_at)); + } + + let question_embedding = + embed_texts_locally(vec![question.trim().to_string()], EmbeddingKind::Query) + .await? + .into_iter() + .next() + .unwrap_or_default(); + let query_vector = vector_literal(&question_embedding)?; + + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" + SELECT + source_slug, + source_title, + chunk_index, + content, + content_preview, + word_count, + (1 - (embedding <=> $1::vector))::float8 AS score + FROM ai_chunks + WHERE embedding IS NOT NULL + ORDER BY embedding <=> $1::vector + LIMIT $2 + "#, + [query_vector.into(), (settings.top_k as i64).into()], + ); + + let matches = SimilarChunkRow::find_by_statement(statement) + .all(&ctx.db) + .await? + .into_iter() + .map(|row| ScoredChunk { + score: row.score, + row: ai_chunks::Model { + created_at: Utc::now().into(), + updated_at: Utc::now().into(), + id: 0, + source_slug: row.source_slug, + source_title: row.source_title, + source_path: None, + source_type: "post".to_string(), + chunk_index: row.chunk_index, + content: row.content, + content_preview: row.content_preview, + embedding: None, + word_count: row.word_count, + }, + }) + .collect::>(); + + Ok((matches, indexed_chunks, last_indexed_at)) +} + +pub async fn rebuild_index(ctx: &AppContext) -> Result { + let settings = load_runtime_settings(ctx, false).await?; + let posts = content::sync_markdown_posts(ctx).await?; + let chunk_drafts = build_chunks(&posts, settings.chunk_size); + let embeddings = if chunk_drafts.is_empty() { + Vec::new() + } else { + embed_texts_locally( + chunk_drafts + .iter() + .map(|chunk| chunk.content.clone()) + .collect::>(), + EmbeddingKind::Passage, + ) + .await? + }; + + ctx.db + .execute(Statement::from_string( + DbBackend::Postgres, + "TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(), + )) + .await?; + + for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) { + let embedding_literal = vector_literal(&embedding)?; + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" + INSERT INTO ai_chunks ( + source_slug, + source_title, + source_path, + source_type, + chunk_index, + content, + content_preview, + embedding, + word_count + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::vector, $9 + ) + "#, + vec![ + draft.source_slug.clone().into(), + draft.source_title.clone().into(), + draft.source_path.clone().into(), + draft.source_type.clone().into(), + draft.chunk_index.into(), + draft.content.clone().into(), + draft.content_preview.clone().into(), + embedding_literal.into(), + draft.word_count.into(), + ], + ); + ctx.db.execute(statement).await?; + } + + let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?; + + Ok(AiIndexSummary { + indexed_chunks: chunk_drafts.len(), + last_indexed_at: Some(last_indexed_at), + }) +} + +pub async fn answer_question(ctx: &AppContext, question: &str) -> Result { + let prepared = prepare_answer(ctx, question).await?; + let answer = if let Some(immediate_answer) = prepared.immediate_answer.clone() { + immediate_answer + } else { + let request = prepared.provider_request.as_ref().ok_or_else(|| { + Error::BadRequest("AI provider request was not prepared".to_string()) + })?; + request_chat_answer(request).await? + }; + + Ok(AiAnswer { + answer, + sources: prepared.sources, + indexed_chunks: prepared.indexed_chunks, + last_indexed_at: prepared.last_indexed_at, + }) +} + +pub fn provider_name(value: Option<&str>) -> String { + trim_to_option(value.map(ToString::to_string)) + .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string()) +} + +pub fn default_api_base() -> &'static str { + DEFAULT_AI_API_BASE +} + +pub fn default_api_key() -> &'static str { + DEFAULT_AI_API_KEY +} + +pub fn default_chat_model() -> &'static str { + DEFAULT_CHAT_MODEL +} + +pub fn local_embedding_label() -> &'static str { + LOCAL_EMBEDDING_MODEL_LABEL +} diff --git a/backend/src/services/content.rs b/backend/src/services/content.rs index 54a4955..084dbec 100644 --- a/backend/src/services/content.rs +++ b/backend/src/services/content.rs @@ -1,13 +1,14 @@ use loco_rs::prelude::*; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, + ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, Set, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; -use crate::models::_entities::{categories, posts, tags}; +use crate::models::_entities::{categories, comments, posts, tags}; pub const MARKDOWN_POSTS_DIR: &str = "content/posts"; const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml"; @@ -120,6 +121,19 @@ fn slugify(value: &str) -> String { slug.trim_matches('-').to_string() } +fn normalized_match_key(value: &str) -> String { + value.trim().to_lowercase() +} + +fn same_text(left: &str, right: &str) -> bool { + normalized_match_key(left) == normalized_match_key(right) +} + +fn text_matches_any(value: &str, keys: &[String]) -> bool { + let current = normalized_match_key(value); + !current.is_empty() && keys.iter().any(|key| current == *key) +} + fn excerpt_from_content(content: &str) -> Option { let mut in_code_block = false; @@ -135,7 +149,11 @@ fn excerpt_from_content(content: &str) -> Option { } let excerpt = trimmed.chars().take(180).collect::(); - return if excerpt.is_empty() { None } else { Some(excerpt) }; + return if excerpt.is_empty() { + None + } else { + Some(excerpt) + }; } None @@ -188,7 +206,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result< let title = trim_to_option(frontmatter.title.clone()) .or_else(|| title_from_content(&content)) .unwrap_or_else(|| slug.clone()); - let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content)); + let description = + trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content)); let category = trim_to_option(frontmatter.category.clone()); let tags = frontmatter .tags @@ -205,7 +224,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result< content: content.trim_start_matches('\n').to_string(), category, tags, - post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()), + post_type: trim_to_option(frontmatter.post_type.clone()) + .unwrap_or_else(|| "article".to_string()), image: trim_to_option(frontmatter.image.clone()), pinned: frontmatter.pinned.unwrap_or(false), published: frontmatter.published.unwrap_or(true), @@ -216,7 +236,12 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result< fn build_markdown_document(post: &MarkdownPost) -> String { let mut lines = vec![ "---".to_string(), - format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()), + format!( + "title: {}", + serde_yaml::to_string(&post.title) + .unwrap_or_else(|_| format!("{:?}", post.title)) + .trim() + ), format!("slug: {}", post.slug), ]; @@ -284,10 +309,16 @@ fn ensure_markdown_posts_bootstrapped() -> Result<()> { image: None, pinned: fixture.pinned.unwrap_or(false), published: fixture.published.unwrap_or(true), - file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(), + file_path: markdown_post_path(&fixture.slug) + .to_string_lossy() + .to_string(), }; - fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?; + fs::write( + markdown_post_path(&fixture.slug), + build_markdown_document(&post), + ) + .map_err(io_error)?; } Ok(()) @@ -312,14 +343,19 @@ async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Resul for post in posts { for tag_name in &post.tags { let slug = slugify(tag_name); + let trimmed = tag_name.trim(); let existing = tags::Entity::find() - .filter(tags::Column::Slug.eq(&slug)) + .filter( + Condition::any() + .add(tags::Column::Slug.eq(&slug)) + .add(tags::Column::Name.eq(trimmed)), + ) .one(&ctx.db) .await?; if existing.is_none() { let item = tags::ActiveModel { - name: Set(Some(tag_name.clone())), + name: Set(Some(trimmed.to_string())), slug: Set(slug), ..Default::default() }; @@ -339,12 +375,21 @@ async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result Result Result Result<()> { + fs::write(markdown_post_path(&post.slug), build_markdown_document(post)).map_err(io_error) +} + +pub fn rewrite_category_references( + current_name: Option<&str>, + current_slug: &str, + next_name: Option<&str>, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + + let mut match_keys = Vec::new(); + if let Some(name) = current_name { + let normalized = normalized_match_key(name); + if !normalized.is_empty() { + match_keys.push(normalized); + } + } + + let normalized_slug = normalized_match_key(current_slug); + if !normalized_slug.is_empty() { + match_keys.push(normalized_slug); + } + + if match_keys.is_empty() { + return Ok(0); + } + + let next_category = next_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut changed = 0_usize; + let mut posts = load_markdown_posts_from_disk()?; + + for post in &mut posts { + let Some(category) = post.category.as_deref() else { + continue; + }; + + if !text_matches_any(category, &match_keys) { + continue; + } + + match &next_category { + Some(updated_name) if same_text(category, updated_name) => {} + Some(updated_name) => { + post.category = Some(updated_name.clone()); + write_markdown_post_to_disk(post)?; + changed += 1; + } + None => { + post.category = None; + write_markdown_post_to_disk(post)?; + changed += 1; + } + } + } + + Ok(changed) +} + +pub fn rewrite_tag_references( + current_name: Option<&str>, + current_slug: &str, + next_name: Option<&str>, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + + let mut match_keys = Vec::new(); + if let Some(name) = current_name { + let normalized = normalized_match_key(name); + if !normalized.is_empty() { + match_keys.push(normalized); + } + } + + let normalized_slug = normalized_match_key(current_slug); + if !normalized_slug.is_empty() { + match_keys.push(normalized_slug); + } + + if match_keys.is_empty() { + return Ok(0); + } + + let next_tag = next_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut changed = 0_usize; + let mut posts = load_markdown_posts_from_disk()?; + + for post in &mut posts { + let mut updated_tags = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut post_changed = false; + + for tag in &post.tags { + if text_matches_any(tag, &match_keys) { + post_changed = true; + if let Some(next_tag_name) = &next_tag { + let normalized = normalized_match_key(next_tag_name); + if seen.insert(normalized) { + updated_tags.push(next_tag_name.clone()); + } + } + continue; + } + + let normalized = normalized_match_key(tag); + if seen.insert(normalized) { + updated_tags.push(tag.clone()); + } + } + + if post_changed { + post.tags = updated_tags; + write_markdown_post_to_disk(post)?; + changed += 1; + } + } + + Ok(changed) +} + async fn dedupe_tags(ctx: &AppContext) -> Result<()> { let existing_tags = tags::Entity::find() .order_by_asc(tags::Column::Id) @@ -425,10 +605,7 @@ async fn dedupe_tags(ctx: &AppContext) -> Result<()> { for tag in existing_tags { let key = if tag.slug.trim().is_empty() { - tag.name - .as_deref() - .map(slugify) - .unwrap_or_default() + tag.name.as_deref().map(slugify).unwrap_or_default() } else { slugify(&tag.slug) }; @@ -453,11 +630,7 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> { for category in existing_categories { let key = if category.slug.trim().is_empty() { - category - .name - .as_deref() - .map(slugify) - .unwrap_or_default() + category.name.as_deref().map(slugify).unwrap_or_default() } else { slugify(&category.slug) }; @@ -474,6 +647,28 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> { pub async fn sync_markdown_posts(ctx: &AppContext) -> Result> { let markdown_posts = load_markdown_posts_from_disk()?; + let markdown_slugs = markdown_posts + .iter() + .map(|post| post.slug.clone()) + .collect::>(); + let existing_posts = posts::Entity::find().all(&ctx.db).await?; + + for stale_post in existing_posts + .into_iter() + .filter(|post| !markdown_slugs.contains(&post.slug)) + { + let stale_slug = stale_post.slug.clone(); + let related_comments = comments::Entity::find() + .filter(comments::Column::PostSlug.eq(&stale_slug)) + .all(&ctx.db) + .await?; + + for comment in related_comments { + let _ = comment.delete(&ctx.db).await; + } + + let _ = stale_post.delete(&ctx.db).await; + } for post in &markdown_posts { let canonical_category = match post.category.as_deref() { @@ -545,6 +740,18 @@ pub async fn write_markdown_document( Ok(updated) } +pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> { + ensure_markdown_posts_bootstrapped()?; + let path = markdown_post_path(slug); + if !path.exists() { + return Err(Error::NotFound); + } + + fs::remove_file(&path).map_err(io_error)?; + sync_markdown_posts(ctx).await?; + Ok(()) +} + pub async fn create_markdown_post( ctx: &AppContext, draft: MarkdownPostDraft, @@ -594,9 +801,16 @@ pub async fn create_markdown_post( file_path: markdown_post_path(&slug).to_string_lossy().to_string(), }; - fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?; + let path = markdown_post_path(&slug); + if path.exists() { + return Err(Error::BadRequest(format!( + "markdown post already exists for slug: {slug}" + ))); + } + + fs::write(&path, build_markdown_document(&post)).map_err(io_error)?; sync_markdown_posts(ctx).await?; - parse_markdown_post(&markdown_post_path(&slug)) + parse_markdown_post(&path) } pub async fn import_markdown_documents( @@ -635,7 +849,8 @@ pub async fn import_markdown_documents( continue; } - fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?; + fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)) + .map_err(io_error)?; imported_slugs.push(slug); } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index ee90b90..08c1b0d 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1 +1,2 @@ +pub mod ai; pub mod content; diff --git a/dev.ps1 b/dev.ps1 index 66c7126..63c56f4 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -1,6 +1,8 @@ param( [switch]$FrontendOnly, [switch]$BackendOnly, + [switch]$McpOnly, + [switch]$WithMcp, [string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" ) @@ -9,9 +11,10 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $frontendScript = Join-Path $repoRoot "start-frontend.ps1" $backendScript = Join-Path $repoRoot "start-backend.ps1" +$mcpScript = Join-Path $repoRoot "start-mcp.ps1" -if ($FrontendOnly -and $BackendOnly) { - throw "Use either -FrontendOnly or -BackendOnly, not both." +if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) { + throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly." } if ($FrontendOnly) { @@ -24,7 +27,13 @@ if ($BackendOnly) { exit $LASTEXITCODE } -Write-Host "[monorepo] Starting frontend and backend in separate PowerShell windows..." -ForegroundColor Cyan +if ($McpOnly) { + & $mcpScript + exit $LASTEXITCODE +} + +$services = if ($WithMcp) { "frontend, backend, and MCP" } else { "frontend and backend" } +Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan Start-Process powershell -ArgumentList @( "-NoExit", @@ -39,4 +48,13 @@ Start-Process powershell -ArgumentList @( "-DatabaseUrl", $DatabaseUrl ) -Write-Host "[monorepo] Frontend window and backend window started." -ForegroundColor Green +if ($WithMcp) { + Start-Process powershell -ArgumentList @( + "-NoExit", + "-ExecutionPolicy", "Bypass", + "-File", $mcpScript + ) +} + +$servicesStarted = if ($WithMcp) { "Frontend, backend, and MCP windows started." } else { "Frontend window and backend window started." } +Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green diff --git a/frontend/src/components/CodeCopyButton.astro b/frontend/src/components/CodeCopyButton.astro index bffc3d6..d5b50e0 100644 --- a/frontend/src/components/CodeCopyButton.astro +++ b/frontend/src/components/CodeCopyButton.astro @@ -5,6 +5,8 @@ diff --git a/frontend/src/components/FriendLinkCard.astro b/frontend/src/components/FriendLinkCard.astro index 2478500..5a86f09 100644 --- a/frontend/src/components/FriendLinkCard.astro +++ b/frontend/src/components/FriendLinkCard.astro @@ -1,4 +1,5 @@ --- +import { getI18n } from '../lib/i18n'; import type { FriendLink } from '../lib/types'; interface Props { @@ -6,6 +7,7 @@ interface Props { } const { friend } = Astro.props; +const { t } = getI18n(Astro); --- {friend.category} ) : ( - external link + {t('friendCard.externalLink')} )} - 访问 + {t('common.visit')} diff --git a/frontend/src/components/Header.astro b/frontend/src/components/Header.astro index e305918..06bc835 100644 --- a/frontend/src/components/Header.astro +++ b/frontend/src/components/Header.astro @@ -1,5 +1,6 @@ --- import { terminalConfig } from '../lib/config/terminal'; +import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n'; import type { SiteSettings } from '../lib/types'; interface Props { @@ -11,11 +12,28 @@ const { siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi' } = Astro.props; -const navItems = terminalConfig.navLinks; +const { locale, t, buildLocaleUrl } = getI18n(Astro); +const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled); +const navItems = [ + { icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' }, + { icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }, + { icon: 'fa-tags', text: t('nav.tags'), href: '/tags' }, + { icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' }, + { icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' }, + { icon: 'fa-link', text: t('nav.friends'), href: '/friends' }, + { icon: 'fa-user-secret', text: t('nav.about'), href: '/about' }, + ...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []), +]; +const localeLinks = SUPPORTED_LOCALES.map((item) => ({ + locale: item, + href: buildLocaleUrl(item), + label: t(`common.languages.${item}`), + shortLabel: item === 'zh-CN' ? '中' : 'EN', +})); const currentPath = Astro.url.pathname; --- -
+
@@ -54,17 +72,39 @@ const currentPath = Astro.url.pathname;
- {category.count} 篇 + {t('common.postsCount', { count: category.count })}

- 浏览 {category.name} 主题下的全部文章和更新记录。 + {t('categories.categoryPosts', { name: category.name })}

@@ -85,7 +89,7 @@ try { ) : (
-

暂无分类数据

+

{t('categories.empty')}

)} diff --git a/frontend/src/pages/friends/index.astro b/frontend/src/pages/friends/index.astro index 0dbc436..3a3eec4 100644 --- a/frontend/src/pages/friends/index.astro +++ b/frontend/src/pages/friends/index.astro @@ -5,11 +5,15 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import FriendLinkCard from '../../components/FriendLinkCard.astro'; import FriendLinkApplication from '../../components/FriendLinkApplication.astro'; import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'; +import { getI18n } from '../../lib/i18n'; import type { AppFriendLink } from '../../lib/api/client'; +export const prerender = false; + let siteSettings = DEFAULT_SITE_SETTINGS; let friendLinks: AppFriendLink[] = []; let error: string | null = null; +const { t } = getI18n(Astro); try { [siteSettings, friendLinks] = await Promise.all([ @@ -18,18 +22,18 @@ try { ]); friendLinks = friendLinks.filter(friend => friend.status === 'approved'); } catch (e) { - error = e instanceof Error ? e.message : 'Failed to fetch friend links'; + error = e instanceof Error ? e.message : t('common.apiUnavailable'); console.error('Failed to fetch friend links:', e); } -const categories = [...new Set(friendLinks.map(friend => friend.category || '其他'))]; +const categories = [...new Set(friendLinks.map(friend => friend.category || t('common.other')))]; const groupedLinks = categories.map(category => ({ category, - links: friendLinks.filter(friend => (friend.category || '其他') === category) + links: friendLinks.filter(friend => (friend.category || t('common.other')) === category) })); --- - +
@@ -41,20 +45,20 @@ const groupedLinks = categories.map(category => ({
-

友情链接

+

{t('friends.title')}

- 这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。 + {t('friends.intro')}

- {friendLinks.length} 个友链 + {t('common.friendsCount', { count: friendLinks.length })} - 仅展示已通过审核 + {t('common.reviewedOnly')}
@@ -78,7 +82,7 @@ const groupedLinks = categories.map(category => ({

{group.category}

-

friend collection

+

{t('friends.collection')}

({group.links.length}) @@ -103,21 +107,21 @@ const groupedLinks = categories.map(category => ({
exchange rules
-

友链交换

+

{t('friends.exchangeRules')}

- 欢迎交换友情链接!请确保您的网站满足以下条件: + {t('friends.exchangeIntro')}

    -
  • 原创内容为主
  • -
  • 网站稳定运行
  • -
  • 无不良内容
  • +
  • {t('friends.rule1')}
  • +
  • {t('friends.rule2')}
  • +
  • {t('friends.rule3')}

- 本站信息:
- 名称: {siteSettings.siteName}
- 描述: {siteSettings.siteDescription}
- 链接: {siteSettings.siteUrl} + {t('friends.siteInfo')}
+ {t('friends.name')}: {siteSettings.siteName}
+ {t('friends.description')}: {siteSettings.siteDescription}
+ {t('friends.link')}: {siteSettings.siteUrl}

diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 3feff54..43e953c 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -10,6 +10,7 @@ import StatsList from '../components/StatsList.astro'; import TechStackList from '../components/TechStackList.astro'; import { terminalConfig } from '../lib/config/terminal'; import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'; +import { formatReadTime, getI18n } from '../lib/i18n'; import type { AppFriendLink } from '../lib/api/client'; import type { Post } from '../lib/types'; @@ -26,6 +27,7 @@ let tags: string[] = []; let friendLinks: AppFriendLink[] = []; let categories: Awaited> = []; let apiError: string | null = null; +const { locale, t } = getI18n(Astro); try { siteSettings = await api.getSiteSettings(); @@ -40,23 +42,32 @@ try { friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved'); categories = await api.getCategories(); } catch (error) { - apiError = error instanceof Error ? error.message : 'API unavailable'; + apiError = error instanceof Error ? error.message : t('common.apiUnavailable'); console.error('API Error:', error); } const systemStats = [ - { label: '文章', value: String(allPosts.length) }, - { label: '标签', value: String(tags.length) }, - { label: '分类', value: String(categories.length) }, - { label: '友链', value: String(friendLinks.length) }, + { label: t('common.posts'), value: String(allPosts.length) }, + { label: t('common.tags'), value: String(tags.length) }, + { label: t('common.categories'), value: String(categories.length) }, + { label: t('common.friends'), value: String(friendLinks.length) }, ]; const techStack = siteSettings.techStack.map(name => ({ name })); const postTypeFilters = [ - { id: 'all', name: '全部', icon: 'fa-stream' }, - { id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' }, - { id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' } + { id: 'all', name: t('common.all'), icon: 'fa-stream' }, + { id: 'article', name: t('common.article'), icon: 'fa-file-alt' }, + { id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' } +]; +const navLinks = [ + { icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' }, + { icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }, + { icon: 'fa-tags', text: t('nav.tags'), href: '/tags' }, + { icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' }, + { icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' }, + { icon: 'fa-link', text: t('nav.friends'), href: '/friends' }, + { icon: 'fa-user-secret', text: t('nav.about'), href: '/about' }, ]; --- @@ -78,7 +89,7 @@ const postTypeFilters = [
- {terminalConfig.navLinks.map(link => ( + {navLinks.map(link => (
-
+
-

{pinnedPost.date} | 阅读时间: {pinnedPost.readTime}

+

{pinnedPost.date} | {t('common.readTime')}: {formatReadTime(locale, pinnedPost.readTime, t)}

{pinnedPost.description}

+
@@ -151,7 +167,7 @@ const postTypeFilters = [ ))}
- +
@@ -178,7 +194,7 @@ const postTypeFilters = [ ))}
- +
@@ -189,15 +205,15 @@ const postTypeFilters = [
-

关于我

+

{t('home.about')}

{siteSettings.ownerBio}

-

技术栈

+

{t('home.techStack')}

-

系统状态

+

{t('home.systemStatus')}

diff --git a/frontend/src/pages/reviews/index.astro b/frontend/src/pages/reviews/index.astro index 51f52c4..b814c75 100644 --- a/frontend/src/pages/reviews/index.astro +++ b/frontend/src/pages/reviews/index.astro @@ -5,8 +5,11 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import FilterPill from '../../components/ui/FilterPill.astro'; import InfoTile from '../../components/ui/InfoTile.astro'; import { apiClient } from '../../lib/api/client'; +import { getI18n } from '../../lib/i18n'; import type { Review } from '../../lib/api/client'; +export const prerender = false; + type ParsedReview = Omit & { tags: string[]; }; @@ -15,6 +18,7 @@ type ParsedReview = Omit & { let reviews: Awaited> = []; const url = new URL(Astro.request.url); const selectedType = url.searchParams.get('type') || 'all'; +const { t } = getI18n(Astro); try { reviews = await apiClient.getReviews(); } catch (error) { @@ -41,20 +45,20 @@ const stats = { }; const filters = [ - { id: 'all', name: '全部', icon: 'fa-list', count: parsedReviews.length }, - { id: 'game', name: '游戏', icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length }, - { id: 'anime', name: '动画', icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length }, - { id: 'music', name: '音乐', icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length }, - { id: 'book', name: '书籍', icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length }, - { id: 'movie', name: '影视', icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length } + { id: 'all', name: t('reviews.typeAll'), icon: 'fa-list', count: parsedReviews.length }, + { id: 'game', name: t('reviews.typeGame'), icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length }, + { id: 'anime', name: t('reviews.typeAnime'), icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length }, + { id: 'music', name: t('reviews.typeMusic'), icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length }, + { id: 'book', name: t('reviews.typeBook'), icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length }, + { id: 'movie', name: t('reviews.typeMovie'), icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length } ]; const typeLabels: Record = { - game: '游戏', - anime: '动画', - music: '音乐', - book: '书籍', - movie: '影视' + game: t('reviews.typeGame'), + anime: t('reviews.typeAnime'), + music: t('reviews.typeMusic'), + book: t('reviews.typeBook'), + movie: t('reviews.typeMovie') }; const typeColors: Record = { @@ -66,7 +70,7 @@ const typeColors: Record = { }; --- - +
@@ -79,13 +83,13 @@ const typeColors: Record = {
review ledger
- - -
-

评价

+ + +
+

{t('reviews.title')}

- 记录游戏、音乐、动画、书籍的体验与感悟 - {selectedType !== 'all' && ` · 当前筛选: ${typeLabels[selectedType] || selectedType}`} + {t('reviews.subtitle')} + {selectedType !== 'all' && ` · ${t('reviews.currentFilter', { type: typeLabels[selectedType] || selectedType })}`}

@@ -97,19 +101,19 @@ const typeColors: Record = {
{stats.total}
-
总评价
+
{t('reviews.total')}
{stats.avgRating}
-
平均评分
+
{t('reviews.average')}
{stats.completed}
-
已完成
+
{t('reviews.completed')}
{stats.inProgress}
-
进行中
+
{t('reviews.inProgress')}
@@ -146,7 +150,7 @@ const typeColors: Record = {
- {parsedReviews.length === 0 ? '暂无评价数据,请检查后端 API 连接' : '当前筛选下暂无评价'} + {parsedReviews.length === 0 ? t('reviews.emptyData') : t('reviews.emptyFiltered')}
) : ( @@ -201,7 +205,7 @@ const typeColors: Record = {
-
当前筛选下暂无评价
+
{t('reviews.emptyFiltered')}
)} @@ -218,16 +222,22 @@ const typeColors: Record = { -