-
-
提供商列表
-
- 可以同时保存多套模型渠道配置,并指定当前实际生效的那一套。
-
-
-
+
+
+
+
文本问答 Provider
+
+ 这里用于站内问答、文章元数据生成和文案润色。
+
+
+
+
+
+
+
+ {form.ai_providers.length ? (
+ form.ai_providers.map((provider, index) => {
+ const active = provider.id === form.ai_active_provider_id
+ const selected = index === selectedProviderIndex
+
+ return (
+
+ )
+ })
+ ) : (
+
+ 还没有配置任何模型提供商,先添加一套即可开始切换使用。
+
+ )}
-
-
- {form.ai_providers.length ? (
- form.ai_providers.map((provider, index) => {
- const active = provider.id === form.ai_active_provider_id
- const selected = index === selectedProviderIndex
-
- return (
-
- )
- })
- ) : (
-
- 还没有配置任何模型提供商,先添加一套即可开始切换使用。
-
- )}
-
-
-
- {form.ai_providers.length ? (
-
-
-
-
- 当前编辑
-
-
- {selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
-
-
- 保存后,系统会使用“当前启用”的提供商处理站内 AI 请求。
-
-
-
-
-
-
-
+
+ {form.ai_providers.length ? (
+
+
+
+
+ 当前编辑
+
+
+ {selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
+
+
+ 保存后,系统会使用“当前启用”的提供商处理文本 AI 请求。
+
+
+
+
+
+
+
+
-
-
- updateAiProvider(selectedProviderIndex, 'name', event.target.value)
- }
- />
-
-
-
- updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
- }
- placeholder="newapi / openai-compatible / 其他兼容值"
- />
-
-
-
- updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
- }
- />
-
-
-
- updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
- }
- />
-
-
-
- updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
- }
- />
-
-
- ) : (
-
- 添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
-
- )}
-
+
+
+ updateAiProvider(selectedProviderIndex, 'name', event.target.value)
+ }
+ />
+
+
+
+
+
。' : undefined}
+ >
+
+ updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
+ }
+ placeholder={selectedProviderIsCloudflare ? 'Cloudflare Account ID 或完整 accounts URL' : undefined}
+ />
+
+
+
+ updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
+ }
+ />
+
+
+
+ updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
+ }
+ />
+
+ {selectedProviderIsCloudflare ? (
+
+
文本问答 / Cloudflare 说明
+
+ - API 地址可直接填 Cloudflare Account ID。
+ - 这里的模型只负责文本问答与后台文字类 AI 能力。
+
+
+ ) : null}
+
+ ) : (
+
+ 添加第一套 provider 后,就可以在这里编辑它的 API 地址、密钥和模型名。
+
+ )}
-
+
- 当前生效:
+ 文本 AI 当前生效:
{activeProvider
- ? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
+ ? `${activeProvider.provider || activeProvider.name} / ${activeProvider.chat_model || '未填写模型'}`
: '未选择提供商'}
+
+
+
+
图片生成(封面)
+
+ 后台“AI 生成封面”单独走这一套配置,不再复用文本问答设置。
+
+
+
+
+
+
+
+
+
+
+
+ 图片 AI 当前配置:
+ {form.ai_image_provider?.trim()
+ ? `${form.ai_image_provider} / ${form.ai_image_model || '未填写模型'}`
+ : '未填写,封面图会回退到旧配置'}
+
+
+ {imageProviderIsCloudflare ? (
+
+
封面图 / Cloudflare 说明
+
+ - API 地址可直接填 Cloudflare Account ID。
+ - 这套配置只用于后台“AI 生成封面”。
+ - 文本问答和图片生成现在是两套独立设置。
+
+
+ ) : null}
+
+
+
+
+
+ 媒体对象存储
+
+ AI 封面图和评测封面上传都会优先走这里的对象存储。支持 Cloudflare R2 / MinIO。
+
+
+
+
+
+
+
+
+
+
当前用途
+
+ - 文章 AI 生成封面:上传到 `post-covers/`
+ - 评测封面上传:上传到 `review-covers/`
+ - {mediaStorageProvider === 'minio' ? '当前会按 MinIO / S3 兼容方式上传。' : '当前会按 Cloudflare R2 方式上传。'}
+
+
+
+
+
索引状态
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index e5c6595..d0718c3 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -330,6 +330,476 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "aws-config"
+version = "1.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-sdk-sso",
+ "aws-sdk-ssooidc",
+ "aws-sdk-sts",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-json 0.62.5",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "hex",
+ "http 1.4.0",
+ "sha1",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-credential-types"
+version = "1.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+]
+
+[[package]]
+name = "aws-runtime"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17"
+dependencies = [
+ "aws-credential-types",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "bytes-utils",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "percent-encoding",
+ "pin-project-lite",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "aws-sdk-s3"
+version = "1.119.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-checksums",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.62.6",
+ "aws-smithy-json 0.61.9",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-smithy-xml",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "hex",
+ "hmac",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "lru",
+ "percent-encoding",
+ "regex-lite",
+ "sha2",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "aws-sdk-sso"
+version = "1.97.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-json 0.62.5",
+ "aws-smithy-observability",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "regex-lite",
+ "tracing",
+]
+
+[[package]]
+name = "aws-sdk-ssooidc"
+version = "1.99.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-json 0.62.5",
+ "aws-smithy-observability",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "regex-lite",
+ "tracing",
+]
+
+[[package]]
+name = "aws-sdk-sts"
+version = "1.101.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-json 0.62.5",
+ "aws-smithy-observability",
+ "aws-smithy-query",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-smithy-xml",
+ "aws-types",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "regex-lite",
+ "tracing",
+]
+
+[[package]]
+name = "aws-sigv4"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "crypto-bigint 0.5.5",
+ "form_urlencoded",
+ "hex",
+ "hmac",
+ "http 0.2.12",
+ "http 1.4.0",
+ "p256",
+ "percent-encoding",
+ "ring",
+ "sha2",
+ "subtle",
+ "time",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-smithy-async"
+version = "1.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc"
+dependencies = [
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "aws-smithy-checksums"
+version = "0.63.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae"
+dependencies = [
+ "aws-smithy-http 0.62.6",
+ "aws-smithy-types",
+ "bytes",
+ "crc-fast",
+ "hex",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "md-5",
+ "pin-project-lite",
+ "sha1",
+ "sha2",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-eventstream"
+version = "0.60.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548"
+dependencies = [
+ "aws-smithy-types",
+ "bytes",
+ "crc32fast",
+]
+
+[[package]]
+name = "aws-smithy-http"
+version = "0.62.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b"
+dependencies = [
+ "aws-smithy-eventstream",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "percent-encoding",
+ "pin-project-lite",
+ "pin-utils",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-http"
+version = "0.63.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project-lite",
+ "pin-utils",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-http-client"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "h2 0.3.27",
+ "h2 0.4.13",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper 1.8.1",
+ "hyper-rustls 0.24.2",
+ "hyper-rustls 0.27.7",
+ "hyper-util",
+ "pin-project-lite",
+ "rustls 0.21.12",
+ "rustls 0.23.37",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower 0.5.3",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-json"
+version = "0.61.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551"
+dependencies = [
+ "aws-smithy-types",
+]
+
+[[package]]
+name = "aws-smithy-json"
+version = "0.62.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a"
+dependencies = [
+ "aws-smithy-types",
+]
+
+[[package]]
+name = "aws-smithy-observability"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c"
+dependencies = [
+ "aws-smithy-runtime-api",
+]
+
+[[package]]
+name = "aws-smithy-query"
+version = "0.60.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd"
+dependencies = [
+ "aws-smithy-types",
+ "urlencoding",
+]
+
+[[package]]
+name = "aws-smithy-runtime"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.6",
+ "aws-smithy-http-client",
+ "aws-smithy-observability",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "http-body-util",
+ "pin-project-lite",
+ "pin-utils",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-runtime-api"
+version = "1.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-types",
+ "bytes",
+ "http 0.2.12",
+ "http 1.4.0",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-smithy-types"
+version = "1.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c"
+dependencies = [
+ "base64-simd",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "http-body-util",
+ "itoa",
+ "num-integer",
+ "pin-project-lite",
+ "pin-utils",
+ "ryu",
+ "serde",
+ "time",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "aws-smithy-xml"
+version = "0.60.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3"
+dependencies = [
+ "xmlparser",
+]
+
+[[package]]
+name = "aws-types"
+version = "1.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "rustc_version",
+ "tracing",
+]
+
[[package]]
name = "axum"
version = "0.8.8"
@@ -341,10 +811,10 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
+ "hyper 1.8.1",
"hyper-util",
"itoa",
"matchit",
@@ -373,8 +843,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
@@ -396,8 +866,8 @@ dependencies = [
"cookie",
"form_urlencoded",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
@@ -434,9 +904,9 @@ dependencies = [
"bytes",
"bytesize",
"cookie",
- "http",
+ "http 1.4.0",
"http-body-util",
- "hyper",
+ "hyper 1.8.1",
"hyper-util",
"mime",
"pretty_assertions",
@@ -474,6 +944,12 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "base16ct"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
+
[[package]]
name = "base64"
version = "0.13.1"
@@ -486,6 +962,16 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -695,6 +1181,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bytes-utils"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
+dependencies = [
+ "bytes",
+ "either",
+]
+
[[package]]
name = "bytesize"
version = "2.3.1"
@@ -831,6 +1327,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -1029,6 +1534,19 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+[[package]]
+name = "crc-fast"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
+dependencies = [
+ "crc",
+ "digest",
+ "rand 0.9.2",
+ "regex",
+ "rustversion",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1118,6 +1636,28 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-bigint"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1209,6 +1749,16 @@ dependencies = [
"parking_lot_core",
]
+[[package]]
+name = "der"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -1402,6 +1952,24 @@ dependencies = [
"duct",
]
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "ecdsa"
+version = "0.14.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
+dependencies = [
+ "der 0.6.1",
+ "elliptic-curve",
+ "rfc6979",
+ "signature 1.6.4",
+]
+
[[package]]
name = "ego-tree"
version = "0.9.0"
@@ -1417,6 +1985,26 @@ dependencies = [
"serde",
]
+[[package]]
+name = "elliptic-curve"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
+dependencies = [
+ "base16ct",
+ "crypto-bigint 0.4.9",
+ "der 0.6.1",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8 0.9.0",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "email-encoding"
version = "0.4.1"
@@ -1588,6 +2176,16 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "ff"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1736,6 +2334,12 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -1986,6 +2590,36 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "group"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -1997,7 +2631,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.0",
"indexmap",
"slab",
"tokio",
@@ -2093,7 +2727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
dependencies = [
"dirs",
- "http",
+ "http 1.4.0",
"indicatif",
"libc",
"log",
@@ -2163,6 +2797,17 @@ dependencies = [
"match_token",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -2173,6 +2818,17 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -2180,7 +2836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -2191,8 +2847,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -2223,6 +2879,30 @@ dependencies = [
"libm",
]
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2233,9 +2913,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.13",
+ "http 1.4.0",
+ "http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
@@ -2246,19 +2926,35 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
+dependencies = [
+ "futures-util",
+ "http 0.2.12",
+ "hyper 0.14.32",
+ "log",
+ "rustls 0.21.12",
+ "tokio",
+ "tokio-rustls 0.24.1",
+]
+
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
- "hyper",
+ "http 1.4.0",
+ "hyper 1.8.1",
"hyper-util",
- "rustls",
+ "rustls 0.23.37",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.6",
]
@@ -2271,7 +2967,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
- "hyper",
+ "hyper 1.8.1",
"hyper-util",
"native-tls",
"tokio",
@@ -2289,9 +2985,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "hyper 1.8.1",
"ipnet",
"libc",
"percent-encoding",
@@ -2762,10 +3458,10 @@ dependencies = [
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
- "rustls",
+ "rustls 0.23.37",
"socket2 0.6.3",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"url",
"webpki-roots 1.0.6",
]
@@ -2934,6 +3630,15 @@ dependencies = [
"imgref",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -3154,7 +3859,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
- "http",
+ "http 1.4.0",
"httparse",
"memchr",
"mime",
@@ -3416,8 +4121,8 @@ dependencies = [
"chrono",
"futures",
"getrandom 0.2.17",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"log",
"md-5",
"percent-encoding",
@@ -3546,6 +4251,23 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+
+[[package]]
+name = "p256"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "sha2",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3764,8 +4486,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der 0.7.10",
- "pkcs8",
- "spki",
+ "pkcs8 0.10.2",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba"
+dependencies = [
+ "der 0.6.1",
+ "spki 0.6.0",
]
[[package]]
@@ -3775,7 +4507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
- "spki",
+ "spki 0.7.3",
]
[[package]]
@@ -4019,7 +4751,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
- "rustls",
+ "rustls 0.23.37",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
@@ -4039,7 +4771,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -4332,6 +5064,12 @@ dependencies = [
"regex-syntax",
]
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -4365,22 +5103,23 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2",
- "http",
- "http-body",
+ "h2 0.4.13",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
- "hyper-rustls",
+ "hyper 1.8.1",
+ "hyper-rustls 0.27.7",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4388,7 +5127,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.3",
"tower-http",
@@ -4410,6 +5149,17 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "rfc6979"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb"
+dependencies = [
+ "crypto-bigint 0.4.9",
+ "hmac",
+ "zeroize",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -4490,10 +5240,10 @@ dependencies = [
"num-integer",
"num-traits",
"pkcs1",
- "pkcs8",
+ "pkcs8 0.10.2",
"rand_core 0.6.4",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
"subtle",
"zeroize",
]
@@ -4537,7 +5287,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
- "http",
+ "http 1.4.0",
"mime",
"rand 0.9.2",
"thiserror 2.0.18",
@@ -4587,21 +5337,46 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.21.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.7",
+ "sct",
+]
+
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
+ "aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
- "rustls-webpki",
+ "rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -4612,12 +5387,23 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-webpki"
+version = "0.101.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -4696,6 +5482,16 @@ dependencies = [
"tendril",
]
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "sdd"
version = "3.0.10"
@@ -4872,6 +5668,20 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+[[package]]
+name = "sec1"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
+dependencies = [
+ "base16ct",
+ "der 0.6.1",
+ "generic-array",
+ "pkcs8 0.9.0",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5172,6 +5982,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "1.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "signature"
version = "2.2.0"
@@ -5292,6 +6112,16 @@ dependencies = [
"lock_api",
]
+[[package]]
+name = "spki"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b"
+dependencies = [
+ "base64ct",
+ "der 0.6.1",
+]
+
[[package]]
name = "spki"
version = "0.7.3"
@@ -5353,7 +6183,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"rust_decimal",
- "rustls",
+ "rustls 0.23.37",
"serde",
"serde_json",
"sha2",
@@ -5729,6 +6559,8 @@ version = "0.1.0"
dependencies = [
"async-stream",
"async-trait",
+ "aws-config",
+ "aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.22.1",
@@ -5961,13 +6793,23 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
+dependencies = [
+ "rustls 0.21.12",
+ "tokio",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls",
+ "rustls 0.23.37",
"tokio",
]
@@ -6104,8 +6946,8 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
@@ -6400,7 +7242,7 @@ dependencies = [
"log",
"native-tls",
"once_cell",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6434,7 +7276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
- "http",
+ "http 1.4.0",
"httparse",
"log",
]
@@ -6451,6 +7293,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -6553,6 +7401,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -7224,6 +8078,12 @@ dependencies = [
"tap",
]
+[[package]]
+name = "xmlparser"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
+
[[package]]
name = "y4m"
version = "0.8.0"
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 32266a1..fda0b4d 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -42,10 +42,12 @@ 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"] }
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
fastembed = "5.1"
async-stream = "0.3"
base64 = "0.22"
+aws-config = "1"
+aws-sdk-s3 = "1"
[[bin]]
name = "termi_api-cli"
diff --git a/backend/assets/views/admin/site_settings.html b/backend/assets/views/admin/site_settings.html
index 4b9ca16..ab3e83a 100644
--- a/backend/assets/views/admin/site_settings.html
+++ b/backend/assets/views/admin/site_settings.html
@@ -83,8 +83,9 @@
关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。
-
+
+
这里是后端适配器类型,不是模型厂商名。`newapi` 表示走 NewAPI 兼容的 Responses 接口;厂商和型号建议写在你的通道备注与模型名里。
@@ -121,7 +122,7 @@
-
文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。
+
文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。前台用户提交过的搜索词和 AI 问题会单独写入分析日志,方便在新版后台里查看。
diff --git a/backend/content/posts/redis.md b/backend/content/posts/redis.md
index 61da6f3..058e75d 100644
--- a/backend/content/posts/redis.md
+++ b/backend/content/posts/redis.md
@@ -1,15 +1,18 @@
---
-title: "Redis常用命令"
-description:
-date: 2022-04-21T09:42:24+08:00
-draft: false
+title: "Redis 安装与常用命令整理"
slug: redis
-image:
-categories:
- - Database
+description: "文章介绍了 Redis 在 Debian 下的安装方法、Windows 图形客户端的安装方式,以及监听端口修改、BitMap、消息队列、LREM 和 Pipeline 等常用操作示例。"
+category: "数据库"
+post_type: "article"
+pinned: false
+published: true
tags:
- - Database
- - Redis
+ - "Redis安装"
+ - "Debian"
+ - "BitMap"
+ - "消息队列"
+ - "Pipeline"
+ - "go-redis"
---
# 安装`Redis`
diff --git a/backend/content/posts/tmux.md b/backend/content/posts/tmux.md
index a5135ae..48c7274 100644
--- a/backend/content/posts/tmux.md
+++ b/backend/content/posts/tmux.md
@@ -1,15 +1,17 @@
---
-title: "如何在 Tmux 会话窗格中发送命令"
-description: 本文介绍了在 Tmux 中发送命令的步骤,包括新建分离会话、发送命令至会话窗格、连接会话窗格、以及发送特殊命令。通过本文,读者将了解如何在 Tmux 中发送命令,并能够更加高效地使用 Tmux。
-date: 2022-08-02T14:54:08+08:00
-draft: false
+title: "在 Tmux 会话窗格中发送命令的方法"
slug: tmux
-image:
-categories:
- - Linux
+description: "介绍如何在 Tmux 中创建分离会话、向指定窗格发送命令并执行回车,同时说明连接会话和发送特殊按键的基本用法。"
+category: "Linux"
+post_type: "article"
+pinned: false
+published: true
tags:
- - Linux
- - Tmux
+ - "Tmux"
+ - "终端复用"
+ - "send-keys"
+ - "会话管理"
+ - "命令行"
---
## 在 Tmux 会话窗格中发送命令的方法
diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs
index d497c28..a0ff59f 100644
--- a/backend/migration/src/lib.rs
+++ b/backend/migration/src/lib.rs
@@ -21,6 +21,10 @@ mod m20260328_000010_add_paragraph_comments_toggle_to_site_settings;
mod m20260328_000011_add_post_images_and_music_playlist;
mod m20260329_000012_add_link_url_to_reviews;
mod m20260329_000013_add_ai_provider_presets_to_site_settings;
+mod m20260329_000014_create_query_events;
+mod m20260330_000015_add_image_ai_settings_to_site_settings;
+mod m20260330_000016_add_r2_media_settings_to_site_settings;
+mod m20260330_000017_add_media_storage_provider_to_site_settings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -46,6 +50,10 @@ impl MigratorTrait for Migrator {
Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration),
Box::new(m20260329_000012_add_link_url_to_reviews::Migration),
Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration),
+ Box::new(m20260329_000014_create_query_events::Migration),
+ Box::new(m20260330_000015_add_image_ai_settings_to_site_settings::Migration),
+ Box::new(m20260330_000016_add_r2_media_settings_to_site_settings::Migration),
+ Box::new(m20260330_000017_add_media_storage_provider_to_site_settings::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/backend/migration/src/m20260329_000014_create_query_events.rs b/backend/migration/src/m20260329_000014_create_query_events.rs
new file mode 100644
index 0000000..ac17085
--- /dev/null
+++ b/backend/migration/src/m20260329_000014_create_query_events.rs
@@ -0,0 +1,73 @@
+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,
+ "query_events",
+ &[
+ ("id", ColType::PkAuto),
+ ("event_type", ColType::String),
+ ("query_text", ColType::Text),
+ ("normalized_query", ColType::Text),
+ ("request_path", ColType::StringNull),
+ ("referrer", ColType::StringNull),
+ ("user_agent", ColType::TextNull),
+ ("result_count", ColType::IntegerNull),
+ ("success", ColType::BooleanNull),
+ ("response_mode", ColType::StringNull),
+ ("provider", ColType::StringNull),
+ ("chat_model", ColType::StringNull),
+ ("latency_ms", ColType::IntegerNull),
+ ],
+ &[],
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .name("idx_query_events_event_type_created_at")
+ .table(Alias::new("query_events"))
+ .col(Alias::new("event_type"))
+ .col(Alias::new("created_at"))
+ .to_owned(),
+ )
+ .await?;
+
+ manager
+ .create_index(
+ Index::create()
+ .name("idx_query_events_normalized_query")
+ .table(Alias::new("query_events"))
+ .col(Alias::new("normalized_query"))
+ .to_owned(),
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ for index_name in [
+ "idx_query_events_normalized_query",
+ "idx_query_events_event_type_created_at",
+ ] {
+ manager
+ .drop_index(
+ Index::drop()
+ .name(index_name)
+ .table(Alias::new("query_events"))
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ drop_table(manager, "query_events").await
+ }
+}
diff --git a/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs b/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs
new file mode 100644
index 0000000..8b00ee1
--- /dev/null
+++ b/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs
@@ -0,0 +1,101 @@
+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_image_provider")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("ai_image_provider"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "ai_image_api_base")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("ai_image_api_base"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "ai_image_api_key")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(ColumnDef::new(Alias::new("ai_image_api_key")).text().null())
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "ai_image_model")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table)
+ .add_column(ColumnDef::new(Alias::new("ai_image_model")).string().null())
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let table = Alias::new("site_settings");
+
+ for column in [
+ "ai_image_model",
+ "ai_image_api_key",
+ "ai_image_api_base",
+ "ai_image_provider",
+ ] {
+ 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/m20260330_000016_add_r2_media_settings_to_site_settings.rs b/backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs
new file mode 100644
index 0000000..a0f1a95
--- /dev/null
+++ b/backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs
@@ -0,0 +1,128 @@
+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", "media_r2_account_id")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("media_r2_account_id"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "media_r2_bucket")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("media_r2_bucket"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "media_r2_public_base_url")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("media_r2_public_base_url"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "media_r2_access_key_id")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table.clone())
+ .add_column(
+ ColumnDef::new(Alias::new("media_r2_access_key_id"))
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ if !manager
+ .has_column("site_settings", "media_r2_secret_access_key")
+ .await?
+ {
+ manager
+ .alter_table(
+ Table::alter()
+ .table(table)
+ .add_column(
+ ColumnDef::new(Alias::new("media_r2_secret_access_key"))
+ .text()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ }
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let table = Alias::new("site_settings");
+
+ for column in [
+ "media_r2_secret_access_key",
+ "media_r2_access_key_id",
+ "media_r2_public_base_url",
+ "media_r2_bucket",
+ "media_r2_account_id",
+ ] {
+ 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/m20260330_000017_add_media_storage_provider_to_site_settings.rs b/backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs
new file mode 100644
index 0000000..d30d598
--- /dev/null
+++ b/backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs
@@ -0,0 +1,53 @@
+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> {
+ if manager
+ .has_column("site_settings", "media_storage_provider")
+ .await?
+ {
+ return Ok(());
+ }
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(SiteSettings::Table)
+ .add_column(
+ ColumnDef::new(SiteSettings::MediaStorageProvider)
+ .string()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ if !manager
+ .has_column("site_settings", "media_storage_provider")
+ .await?
+ {
+ return Ok(());
+ }
+
+ manager
+ .alter_table(
+ Table::alter()
+ .table(SiteSettings::Table)
+ .drop_column(SiteSettings::MediaStorageProvider)
+ .to_owned(),
+ )
+ .await
+ }
+}
+
+#[derive(DeriveIden)]
+enum SiteSettings {
+ Table,
+ MediaStorageProvider,
+}
diff --git a/backend/src/controllers/admin_api.rs b/backend/src/controllers/admin_api.rs
index 14ba21d..424454f 100644
--- a/backend/src/controllers/admin_api.rs
+++ b/backend/src/controllers/admin_api.rs
@@ -1,3 +1,4 @@
+use axum::extract::{Multipart, Query};
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
@@ -14,7 +15,7 @@ use crate::{
site_settings::{self, SiteSettingsPayload},
},
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
- services::{ai, content},
+ services::{ai, analytics, content, storage},
};
#[derive(Clone, Debug, Deserialize)]
@@ -130,6 +131,10 @@ pub struct AdminSiteSettingsResponse {
pub ai_api_base: Option
,
pub ai_api_key: Option,
pub ai_chat_model: Option,
+ pub ai_image_provider: Option,
+ pub ai_image_api_base: Option,
+ pub ai_image_api_key: Option,
+ pub ai_image_model: Option,
pub ai_providers: Vec,
pub ai_active_provider_id: Option,
pub ai_embedding_model: Option,
@@ -139,6 +144,12 @@ pub struct AdminSiteSettingsResponse {
pub ai_last_indexed_at: Option,
pub ai_chunks_count: u64,
pub ai_local_embedding: String,
+ pub media_storage_provider: Option,
+ pub media_r2_account_id: Option,
+ pub media_r2_bucket: Option,
+ pub media_r2_public_base_url: Option,
+ pub media_r2_access_key_id: Option,
+ pub media_r2_secret_access_key: Option,
}
#[derive(Clone, Debug, Serialize)]
@@ -160,6 +171,67 @@ pub struct AdminAiProviderTestResponse {
pub reply_preview: String,
}
+#[derive(Clone, Debug, Deserialize)]
+pub struct AdminAiImageProviderTestRequest {
+ pub provider: String,
+ pub api_base: String,
+ pub api_key: String,
+ pub image_model: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminAiImageProviderTestResponse {
+ pub provider: String,
+ pub endpoint: String,
+ pub image_model: String,
+ pub result_preview: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminImageUploadResponse {
+ pub url: String,
+ pub key: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminR2ConnectivityResponse {
+ pub bucket: String,
+ pub public_base_url: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminMediaObjectResponse {
+ pub key: String,
+ pub url: String,
+ pub size_bytes: i64,
+ pub last_modified: Option,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminMediaListResponse {
+ pub provider: String,
+ pub bucket: String,
+ pub public_base_url: String,
+ pub items: Vec,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct AdminMediaDeleteResponse {
+ pub deleted: bool,
+ pub key: String,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct AdminMediaListQuery {
+ pub prefix: Option,
+ pub limit: Option,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct AdminMediaDeleteQuery {
+ pub key: String,
+}
+
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostMetadataRequest {
pub markdown: String,
@@ -170,6 +242,30 @@ pub struct AdminPostPolishRequest {
pub markdown: String,
}
+#[derive(Clone, Debug, Deserialize)]
+pub struct AdminReviewPolishRequest {
+ pub title: String,
+ pub review_type: String,
+ pub rating: i32,
+ pub review_date: Option,
+ pub status: String,
+ #[serde(default)]
+ pub tags: Vec,
+ pub description: String,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct AdminPostCoverImageRequest {
+ pub title: String,
+ pub description: Option,
+ pub category: Option,
+ #[serde(default)]
+ pub tags: Vec,
+ pub post_type: String,
+ pub slug: Option,
+ pub markdown: String,
+}
+
fn format_timestamp(
value: Option,
pattern: &str,
@@ -242,6 +338,10 @@ fn build_settings_response(
ai_api_base: item.ai_api_base,
ai_api_key: item.ai_api_key,
ai_chat_model: item.ai_chat_model,
+ ai_image_provider: item.ai_image_provider,
+ ai_image_api_base: item.ai_image_api_base,
+ ai_image_api_key: item.ai_image_api_key,
+ ai_image_model: item.ai_image_model,
ai_providers,
ai_active_provider_id,
ai_embedding_model: item.ai_embedding_model,
@@ -251,6 +351,12 @@ fn build_settings_response(
ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
ai_chunks_count,
ai_local_embedding: ai::local_embedding_label().to_string(),
+ media_storage_provider: item.media_storage_provider,
+ media_r2_account_id: item.media_r2_account_id,
+ media_r2_bucket: item.media_r2_bucket,
+ media_r2_public_base_url: item.media_r2_public_base_url,
+ media_r2_access_key_id: item.media_r2_access_key_id,
+ media_r2_secret_access_key: item.media_r2_secret_access_key,
}
}
@@ -410,6 +516,12 @@ pub async fn dashboard(State(ctx): State) -> Result {
})
}
+#[debug_handler]
+pub async fn analytics_overview(State(ctx): State) -> Result {
+ check_auth()?;
+ format::json(analytics::build_admin_analytics(&ctx).await?)
+}
+
#[debug_handler]
pub async fn get_site_settings(State(ctx): State) -> Result {
check_auth()?;
@@ -428,7 +540,7 @@ pub async fn update_site_settings(
let current = site_settings::load_current(&ctx).await?;
let mut item = current;
params.apply(&mut item);
- let item = item.into_active_model();
+ let item = item.into_active_model().reset_all();
let updated = item.update(&ctx.db).await?;
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
@@ -469,6 +581,88 @@ pub async fn test_ai_provider(Json(payload): Json) -
})
}
+#[debug_handler]
+pub async fn test_ai_image_provider(
+ Json(payload): Json,
+) -> Result {
+ check_auth()?;
+
+ let result = ai::test_image_provider_connectivity(
+ &payload.provider,
+ &payload.api_base,
+ &payload.api_key,
+ &payload.image_model,
+ )
+ .await?;
+
+ format::json(AdminAiImageProviderTestResponse {
+ provider: result.provider,
+ endpoint: result.endpoint,
+ image_model: result.image_model,
+ result_preview: result.result_preview,
+ })
+}
+
+#[debug_handler]
+pub async fn test_r2_storage(State(ctx): State) -> Result {
+ check_auth()?;
+
+ let settings = storage::require_r2_settings(&ctx).await?;
+ let bucket = storage::test_r2_connectivity(&ctx).await?;
+
+ format::json(AdminR2ConnectivityResponse {
+ bucket,
+ public_base_url: settings.public_base_url,
+ })
+}
+
+#[debug_handler]
+pub async fn list_media_objects(
+ State(ctx): State,
+ Query(query): Query,
+) -> Result {
+ check_auth()?;
+
+ let settings = storage::require_r2_settings(&ctx).await?;
+ let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
+ .await?
+ .into_iter()
+ .map(|item| AdminMediaObjectResponse {
+ key: item.key,
+ url: item.url,
+ size_bytes: item.size_bytes,
+ last_modified: item.last_modified,
+ })
+ .collect::>();
+
+ format::json(AdminMediaListResponse {
+ provider: settings.provider_name,
+ bucket: settings.bucket,
+ public_base_url: settings.public_base_url,
+ items,
+ })
+}
+
+#[debug_handler]
+pub async fn delete_media_object(
+ State(ctx): State,
+ Query(query): Query,
+) -> Result {
+ check_auth()?;
+
+ let key = query.key.trim();
+ if key.is_empty() {
+ return Err(Error::BadRequest("缺少对象 key".to_string()));
+ }
+
+ storage::delete_object(&ctx, key).await?;
+
+ format::json(AdminMediaDeleteResponse {
+ deleted: true,
+ key: key.to_string(),
+ })
+}
+
#[debug_handler]
pub async fn generate_post_metadata(
State(ctx): State,
@@ -487,6 +681,127 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
}
+#[debug_handler]
+pub async fn polish_review_description(
+ State(ctx): State,
+ Json(payload): Json,
+) -> Result {
+ check_auth()?;
+ format::json(
+ ai::polish_review_description(
+ &ctx,
+ &payload.title,
+ &payload.review_type,
+ payload.rating,
+ payload.review_date.as_deref(),
+ &payload.status,
+ &payload.tags,
+ &payload.description,
+ )
+ .await?,
+ )
+}
+
+#[debug_handler]
+pub async fn generate_post_cover_image(
+ State(ctx): State,
+ Json(payload): Json,
+) -> Result {
+ check_auth()?;
+ format::json(
+ ai::generate_post_cover_image(
+ &ctx,
+ &payload.title,
+ payload.description.as_deref(),
+ payload.category.as_deref(),
+ &payload.tags,
+ &payload.post_type,
+ payload.slug.as_deref(),
+ &payload.markdown,
+ )
+ .await?,
+ )
+}
+
+fn review_cover_extension(
+ file_name: Option<&str>,
+ content_type: Option<&str>,
+) -> Option<&'static str> {
+ let from_file_name = file_name
+ .and_then(|name| name.rsplit('.').next())
+ .map(|ext| ext.trim().to_ascii_lowercase());
+
+ match from_file_name.as_deref() {
+ Some("png") => return Some("png"),
+ Some("jpg") | Some("jpeg") => return Some("jpg"),
+ Some("webp") => return Some("webp"),
+ Some("gif") => return Some("gif"),
+ Some("avif") => return Some("avif"),
+ Some("svg") => return Some("svg"),
+ _ => {}
+ }
+
+ match content_type
+ .unwrap_or_default()
+ .trim()
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "image/png" => Some("png"),
+ "image/jpeg" => Some("jpg"),
+ "image/webp" => Some("webp"),
+ "image/gif" => Some("gif"),
+ "image/avif" => Some("avif"),
+ "image/svg+xml" => Some("svg"),
+ _ => None,
+ }
+}
+
+#[debug_handler]
+pub async fn upload_review_cover_image(
+ State(ctx): State,
+ mut multipart: Multipart,
+) -> Result {
+ check_auth()?;
+
+ let field = multipart
+ .next_field()
+ .await
+ .map_err(|error| Error::BadRequest(error.to_string()))?
+ .ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
+ let file_name = field.file_name().map(ToString::to_string);
+ let content_type = field.content_type().map(ToString::to_string);
+ let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
+ .ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
+ let bytes = field
+ .bytes()
+ .await
+ .map_err(|error| Error::BadRequest(error.to_string()))?;
+
+ if bytes.is_empty() {
+ return Err(Error::BadRequest("上传的图片内容为空".to_string()));
+ }
+
+ let key = crate::services::storage::build_object_key(
+ "review-covers",
+ file_name.as_deref().unwrap_or("review-cover"),
+ extension,
+ );
+ let stored = crate::services::storage::upload_bytes_to_r2(
+ &ctx,
+ &key,
+ bytes.to_vec(),
+ content_type.as_deref(),
+ Some("public, max-age=31536000, immutable"),
+ )
+ .await?;
+
+ format::json(AdminImageUploadResponse {
+ url: stored.url,
+ key: stored.key,
+ })
+}
+
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
@@ -494,11 +809,21 @@ pub fn routes() -> Routes {
.add("/session", delete(session_logout))
.add("/session/login", post(session_login))
.add("/dashboard", get(dashboard))
+ .add("/analytics", get(analytics_overview))
.add("/site-settings", get(get_site_settings))
.add("/site-settings", patch(update_site_settings))
.add("/site-settings", put(update_site_settings))
.add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider))
+ .add("/ai/test-image-provider", post(test_ai_image_provider))
+ .add("/storage/r2/test", post(test_r2_storage))
+ .add(
+ "/storage/media",
+ get(list_media_objects).delete(delete_media_object),
+ )
.add("/ai/post-metadata", post(generate_post_metadata))
.add("/ai/polish-post", post(polish_post_markdown))
+ .add("/ai/polish-review", post(polish_review_description))
+ .add("/ai/post-cover", post(generate_post_cover_image))
+ .add("/storage/review-cover", post(upload_review_cover_image))
}
diff --git a/backend/src/controllers/ai.rs b/backend/src/controllers/ai.rs
index 4a0a40d..3db98f5 100644
--- a/backend/src/controllers/ai.rs
+++ b/backend/src/controllers/ai.rs
@@ -5,15 +5,19 @@ use axum::{
body::{Body, Bytes},
http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
- HeaderValue,
+ HeaderMap, HeaderValue,
},
};
use chrono::{DateTime, Utc};
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use std::time::Instant;
-use crate::{controllers::admin::check_auth, services::ai};
+use crate::{
+ controllers::{admin::check_auth, site_settings},
+ services::{ai, analytics},
+};
#[derive(Clone, Debug, Deserialize)]
pub struct AskPayload {
@@ -55,6 +59,30 @@ fn format_timestamp(value: Option>) -> Option {
value.map(|item| item.to_rfc3339())
}
+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)
+ }
+ })
+}
+
+async fn current_provider_metadata(ctx: &AppContext) -> (Option, Option) {
+ match site_settings::load_current(ctx).await {
+ Ok(settings) => (
+ trim_to_option(settings.ai_provider),
+ trim_to_option(settings.ai_chat_model),
+ ),
+ Err(error) => {
+ tracing::warn!("failed to load ai provider metadata for analytics: {error}");
+ (None, None)
+ }
+ }
+}
+
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());
@@ -178,24 +206,66 @@ fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskRes
#[debug_handler]
pub async fn ask(
State(ctx): State,
+ headers: HeaderMap,
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),
- })
+ let started_at = Instant::now();
+ let question = payload.question.trim().to_string();
+ let (provider, chat_model) = current_provider_metadata(&ctx).await;
+
+ match ai::answer_question(&ctx, &payload.question).await {
+ Ok(result) => {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &headers,
+ true,
+ "sync",
+ provider,
+ chat_model,
+ Some(result.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
+
+ format::json(AskResponse {
+ question,
+ answer: result.answer,
+ sources: result.sources,
+ indexed_chunks: result.indexed_chunks,
+ last_indexed_at: format_timestamp(result.last_indexed_at),
+ })
+ }
+ Err(error) => {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &headers,
+ false,
+ "sync",
+ provider,
+ chat_model,
+ None,
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
+ Err(error)
+ }
+ }
}
#[debug_handler]
pub async fn ask_stream(
State(ctx): State,
+ headers: HeaderMap,
Json(payload): Json,
) -> Result {
+ let request_headers = headers.clone();
+ let question = payload.question.trim().to_string();
+ let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await;
+
let stream = stream! {
+ let started_at = Instant::now();
yield Ok::(sse_bytes("status", &StreamStatusEvent {
phase: "retrieving".to_string(),
message: "正在检索知识库上下文...".to_string(),
@@ -204,6 +274,18 @@ pub async fn ask_stream(
let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
Ok(prepared) => prepared,
Err(error) => {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ false,
+ "stream",
+ fallback_provider.clone(),
+ fallback_chat_model.clone(),
+ None,
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
yield Ok(sse_bytes("error", &StreamErrorEvent {
message: error.to_string(),
}));
@@ -212,6 +294,16 @@ pub async fn ask_stream(
};
let mut accumulated_answer = String::new();
+ let active_provider = prepared
+ .provider_request
+ .as_ref()
+ .map(|request| request.provider.clone())
+ .or_else(|| fallback_provider.clone());
+ let active_chat_model = prepared
+ .provider_request
+ .as_ref()
+ .map(|request| request.chat_model.clone())
+ .or_else(|| fallback_chat_model.clone());
if let Some(answer) = prepared.immediate_answer.as_deref() {
yield Ok(sse_bytes("status", &StreamStatusEvent {
@@ -241,6 +333,18 @@ pub async fn ask_stream(
let mut response = match response {
Ok(response) => response,
Err(error) => {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ false,
+ "stream",
+ active_provider.clone(),
+ active_chat_model.clone(),
+ Some(prepared.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI request failed: {error}"),
}));
@@ -251,6 +355,18 @@ pub async fn ask_stream(
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ false,
+ "stream",
+ active_provider.clone(),
+ active_chat_model.clone(),
+ Some(prepared.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI provider returned {status}: {body}"),
}));
@@ -265,6 +381,18 @@ pub async fn ask_stream(
let Some(chunk) = (match next_chunk {
Ok(chunk) => chunk,
Err(error) => {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ false,
+ "stream",
+ active_provider.clone(),
+ active_chat_model.clone(),
+ Some(prepared.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
yield Ok(sse_bytes("error", &StreamErrorEvent {
message: format!("AI stream read failed: {error}"),
}));
@@ -323,6 +451,18 @@ pub async fn ask_stream(
}
if accumulated_answer.is_empty() {
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ false,
+ "stream",
+ active_provider.clone(),
+ active_chat_model.clone(),
+ Some(prepared.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
yield Ok(sse_bytes("error", &StreamErrorEvent {
message: "AI chat response did not contain readable content".to_string(),
}));
@@ -330,6 +470,19 @@ pub async fn ask_stream(
}
}
+ analytics::record_ai_question_event(
+ &ctx,
+ &question,
+ &request_headers,
+ true,
+ "stream",
+ active_provider,
+ active_chat_model,
+ Some(prepared.sources.len()),
+ started_at.elapsed().as_millis() as i64,
+ )
+ .await;
+
let final_payload = build_ask_response(&prepared, accumulated_answer);
yield Ok(sse_bytes("complete", &final_payload));
};
diff --git a/backend/src/controllers/review.rs b/backend/src/controllers/review.rs
index 691dfee..bc9ea21 100644
--- a/backend/src/controllers/review.rs
+++ b/backend/src/controllers/review.rs
@@ -3,7 +3,10 @@ use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};
-use crate::models::_entities::reviews::{self, Entity as ReviewEntity};
+use crate::{
+ models::_entities::reviews::{self, Entity as ReviewEntity},
+ services::storage,
+};
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateReviewRequest {
@@ -83,9 +86,11 @@ pub async fn update(
) -> Result {
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
- let Some(mut review) = review.map(|r| r.into_active_model()) else {
+ let Some(existing_review) = review else {
return Err(Error::NotFound);
};
+ let old_cover = existing_review.cover.clone();
+ let mut review = existing_review.into_active_model();
if let Some(title) = req.title {
review.title = Set(Some(title));
@@ -108,7 +113,9 @@ pub async fn update(
if let Some(tags) = req.tags {
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
}
+ let mut next_cover = old_cover.clone();
if let Some(cover) = req.cover {
+ next_cover = Some(cover.clone());
review.cover = Set(Some(cover));
}
if let Some(link_url) = req.link_url {
@@ -117,6 +124,14 @@ pub async fn update(
}
let review = review.update(&ctx.db).await?;
+ if let Some(old_cover) = old_cover
+ .filter(|old| Some(old.clone()) != next_cover)
+ .filter(|old| !old.trim().is_empty())
+ {
+ if let Err(error) = storage::delete_managed_url(&ctx, &old_cover).await {
+ tracing::warn!("failed to cleanup replaced review cover: {error}");
+ }
+ }
format::json(review)
}
@@ -128,7 +143,13 @@ pub async fn remove(
match review {
Some(r) => {
+ let cover = r.cover.clone();
r.delete(&ctx.db).await?;
+ if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) {
+ if let Err(error) = storage::delete_managed_url(&ctx, &cover).await {
+ tracing::warn!("failed to cleanup deleted review cover: {error}");
+ }
+ }
format::empty()
}
None => Err(Error::NotFound),
diff --git a/backend/src/controllers/search.rs b/backend/src/controllers/search.rs
index b22b75f..0bede35 100644
--- a/backend/src/controllers/search.rs
+++ b/backend/src/controllers/search.rs
@@ -1,15 +1,46 @@
+use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
+use std::time::Instant;
use crate::models::_entities::posts;
-use crate::services::content;
+use crate::services::{analytics, content};
+
+fn deserialize_boolish_option<'de, D>(
+ deserializer: D,
+) -> std::result::Result