mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: finalize image search workflow and release v0.76.0
This commit is contained in:
+5
-5
@@ -1,4 +1,4 @@
|
||||
# Release v0.75.0
|
||||
# Release v0.76.0
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
@@ -176,9 +176,9 @@
|
||||
### 版本号
|
||||
|
||||
已自动同步到:
|
||||
- `package.json`: 0.75.0
|
||||
- `src-tauri/Cargo.toml`: 0.75.0
|
||||
- `src-tauri/tauri.conf.json`: 0.75.0
|
||||
- `package.json`: 0.76.0
|
||||
- `src-tauri/Cargo.toml`: 0.76.0
|
||||
- `src-tauri/tauri.conf.json`: 0.76.0
|
||||
|
||||
### 新功能使用
|
||||
|
||||
@@ -213,4 +213,4 @@ AI:[自动打开天气网站并读取内容] 今天晴天,20-25°C...
|
||||
|
||||
---
|
||||
|
||||
**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.74.0...v0.75.0
|
||||
**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.75.0...v0.76.0
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.75.0",
|
||||
"version": "0.76.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -50,6 +50,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.6",
|
||||
|
||||
Generated
+515
-21
@@ -2,6 +2,163 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "actix-codec"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-rt",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"language-tags",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-router"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
|
||||
dependencies = [
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"http 0.2.12",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-rt"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-server"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
|
||||
dependencies = [
|
||||
"actix-rt",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"mio 1.1.1",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-service"
|
||||
version = "2.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-utils"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
|
||||
dependencies = [
|
||||
"local-waker",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "actix-web"
|
||||
version = "4.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-http",
|
||||
"actix-router",
|
||||
"actix-rt",
|
||||
"actix-server",
|
||||
"actix-service",
|
||||
"actix-utils",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"impl-more",
|
||||
"itoa",
|
||||
"language-tags",
|
||||
"log",
|
||||
"mime",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2 0.6.2",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -1083,6 +1240,21 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -1518,6 +1690,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytestring"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
@@ -1923,6 +2104,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -2416,6 +2606,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debugid"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -2474,13 +2674,36 @@ version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version 0.4.1",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version 0.4.1",
|
||||
"syn 2.0.114",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -3030,6 +3253,18 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "findshlibs"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -3503,6 +3738,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.32.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -4085,7 +4326,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4273,6 +4514,12 @@ dependencies = [
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
@@ -4665,6 +4912,12 @@ dependencies = [
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -4818,6 +5071,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "local-waker"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -5422,7 +5681,7 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -5609,6 +5868,16 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
@@ -5750,8 +6019,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.3",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image 0.3.2",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
@@ -5771,6 +6059,15 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
@@ -6003,6 +6300,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix 0.30.1",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@@ -6677,7 +6990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.13.0",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -6685,7 +6998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -6745,6 +7058,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"scopeguard",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -6785,7 +7099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -6809,7 +7123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6825,7 +7139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -6865,7 +7179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -6900,7 +7214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -6920,7 +7234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"glob",
|
||||
@@ -6951,7 +7265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6970,7 +7284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7022,7 +7336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum 0.7.9",
|
||||
@@ -7065,7 +7379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7080,7 +7394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7121,7 +7435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7137,7 +7451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7164,7 +7478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -7813,6 +8127,12 @@ dependencies = [
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -8147,7 +8467,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cssparser 0.29.6",
|
||||
"derive_more",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"phf 0.8.0",
|
||||
@@ -8165,7 +8485,7 @@ checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cssparser 0.31.2",
|
||||
"derive_more",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
@@ -8207,6 +8527,127 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
|
||||
[[package]]
|
||||
name = "sentry"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc02afbba55340d6394968cb621384dbb9a0ad72d1b45e3e403099702a8bcf61"
|
||||
dependencies = [
|
||||
"httpdate",
|
||||
"native-tls",
|
||||
"reqwest 0.12.28",
|
||||
"sentry-actix",
|
||||
"sentry-backtrace",
|
||||
"sentry-contexts",
|
||||
"sentry-core",
|
||||
"sentry-debug-images",
|
||||
"sentry-panic",
|
||||
"sentry-tracing",
|
||||
"tokio",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-actix"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5c9138631299216c4b85be2e54b9ca3c40e54dabeac4fa71ee4a680d536e083"
|
||||
dependencies = [
|
||||
"actix-http",
|
||||
"actix-web",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"sentry-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-backtrace"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f288792b92dfb3e8887554d2d11418863d5fabcd1cfad6756a8f88a5b963b76"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"regex",
|
||||
"sentry-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-contexts"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b28e6208b0f6e28a3d08a6e46b1127fefe75185decb969b8e1e0bf8cce6e06d"
|
||||
dependencies = [
|
||||
"hostname",
|
||||
"libc",
|
||||
"os_info",
|
||||
"rustc_version 0.4.1",
|
||||
"sentry-core",
|
||||
"uname",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-core"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67600a47b5aa005cb701454d56a289135a80654eebf1c1d9f076c3817a249bb8"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"sentry-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-debug-images"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb27e7e12008f0c80446089efe050f3333a333aafc9eb16fe9800b7a183cf20c"
|
||||
dependencies = [
|
||||
"findshlibs",
|
||||
"sentry-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-panic"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce0bae293ecf6afaaa00dc63de3cc89b49ef774f06b9cf18dfd9c652009d3461"
|
||||
dependencies = [
|
||||
"sentry-backtrace",
|
||||
"sentry-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-tracing"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c044617771f460a3c2ca7cf8f516c3341ba886ca38d53cb550997d8bf636a3c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"sentry-backtrace",
|
||||
"sentry-core",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-types"
|
||||
version = "0.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa369b6dc823ba5bddd69d16620f19229eb5df865b9483cf4dabb608891bb9c2"
|
||||
dependencies = [
|
||||
"debugid",
|
||||
"hex",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -10262,6 +10703,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uname"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
@@ -10366,6 +10816,12 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
@@ -10394,6 +10850,35 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"der",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf-8",
|
||||
"webpki-root-certs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -10792,6 +11277,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[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.25.4"
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -50,6 +50,7 @@ thiserror = "1"
|
||||
# 日志
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
sentry = "0.43"
|
||||
|
||||
# HTTP 服务器
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
@@ -189,7 +190,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.75.0"
|
||||
version = "0.76.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -252,6 +253,7 @@ thiserror.workspace = true
|
||||
# 日志
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
sentry.workspace = true
|
||||
|
||||
# HTTP 服务器
|
||||
axum.workspace = true
|
||||
|
||||
@@ -22,14 +22,14 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde};
|
||||
pub use types::{
|
||||
generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry,
|
||||
AsrProviderType, AssistantConfig, AssistantProfile, BaiduConfig, ChannelsConfig,
|
||||
ChatAppearanceConfig, Config, ContentCreatorConfig, ConversationSettings, CredentialEntry,
|
||||
CredentialPoolConfig, CustomProviderConfig, DeliveryConfig, EndpointProvidersConfig,
|
||||
ExperimentalFeatures, GeminiApiKeyEntry, HeartbeatExecutionMode, HeartbeatSecurityConfig,
|
||||
HeartbeatSettings, HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig,
|
||||
InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig,
|
||||
MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig,
|
||||
NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig,
|
||||
ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, RateLimitSettings,
|
||||
ChatAppearanceConfig, Config, ContentCreatorConfig, ConversationSettings, CrashReportingConfig,
|
||||
CredentialEntry, CredentialPoolConfig, CustomProviderConfig, DeliveryConfig,
|
||||
EndpointProvidersConfig, ExperimentalFeatures, GeminiApiKeyEntry, HeartbeatExecutionMode,
|
||||
HeartbeatSecurityConfig, HeartbeatSettings, HintRouteSettingsEntry, HintRouterSettings,
|
||||
ImageGenConfig, InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig,
|
||||
MemoryConfig, MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo,
|
||||
ModelsConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, PairingSettings,
|
||||
ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, RateLimitSettings,
|
||||
RemoteManagementConfig, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine,
|
||||
ServerConfig, TaskSchedule, TlsConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
|
||||
VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig,
|
||||
|
||||
@@ -429,6 +429,9 @@ pub struct Config {
|
||||
/// 速率限制配置
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitSettings,
|
||||
/// 崩溃上报配置(Sentry 协议兼容)
|
||||
#[serde(default)]
|
||||
pub crash_reporting: CrashReportingConfig,
|
||||
/// 对话管理配置
|
||||
#[serde(default)]
|
||||
pub conversation: ConversationSettings,
|
||||
@@ -1285,6 +1288,50 @@ pub struct LoggingConfig {
|
||||
pub include_request_body: bool,
|
||||
}
|
||||
|
||||
/// 崩溃上报配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CrashReportingConfig {
|
||||
/// 是否启用崩溃上报
|
||||
#[serde(default = "default_crash_reporting_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Sentry DSN(为空时不发送远端)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dsn: Option<String>,
|
||||
/// 上报环境(如 prod/dev)
|
||||
#[serde(default = "default_crash_reporting_environment")]
|
||||
pub environment: String,
|
||||
/// 采样率(0.0 - 1.0)
|
||||
#[serde(default = "default_crash_reporting_sample_rate")]
|
||||
pub sample_rate: f64,
|
||||
/// 是否发送可能包含 PII 的默认字段
|
||||
#[serde(default)]
|
||||
pub send_pii: bool,
|
||||
}
|
||||
|
||||
fn default_crash_reporting_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_crash_reporting_environment() -> String {
|
||||
"production".to_string()
|
||||
}
|
||||
|
||||
fn default_crash_reporting_sample_rate() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Default for CrashReportingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_crash_reporting_enabled(),
|
||||
dsn: None,
|
||||
environment: default_crash_reporting_environment(),
|
||||
sample_rate: default_crash_reporting_sample_rate(),
|
||||
send_pii: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_logging_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -1788,6 +1835,7 @@ impl Default for Config {
|
||||
assistant: AssistantConfig::default(),
|
||||
user_profile: UserProfile::default(),
|
||||
rate_limit: RateLimitSettings::default(),
|
||||
crash_reporting: CrashReportingConfig::default(),
|
||||
conversation: ConversationSettings::default(),
|
||||
hint_router: HintRouterSettings::default(),
|
||||
pairing: PairingSettings::default(),
|
||||
@@ -2054,6 +2102,12 @@ pub struct ImageGenConfig {
|
||||
/// 自动下载生成的图像
|
||||
#[serde(default)]
|
||||
pub auto_download: Option<bool>,
|
||||
/// 图片搜索(Pexels)API Key
|
||||
#[serde(default)]
|
||||
pub image_search_pexels_api_key: Option<String>,
|
||||
/// 图片搜索(Pixabay)API Key
|
||||
#[serde(default)]
|
||||
pub image_search_pixabay_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 助理配置
|
||||
@@ -2139,6 +2193,11 @@ mod unit_tests {
|
||||
assert_eq!(config.auth_dir, "~/.proxycast/auth");
|
||||
assert!(config.credential_pool.kiro.is_empty());
|
||||
assert!(config.credential_pool.openai.is_empty());
|
||||
assert!(config.crash_reporting.enabled);
|
||||
assert!(config.crash_reporting.dsn.is_none());
|
||||
assert_eq!(config.crash_reporting.environment, "production");
|
||||
assert_eq!(config.crash_reporting.sample_rate, 1.0);
|
||||
assert!(!config.crash_reporting.send_pii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::app::types::{AppState, LogState};
|
||||
use crate::app::utils::is_valid_bind_host;
|
||||
use crate::config::{
|
||||
self,
|
||||
observer::{ConfigChangeEvent, RoutingChangeEvent},
|
||||
observer::{ConfigChangeEvent, FullReloadEvent, RoutingChangeEvent},
|
||||
ConfigChangeSource, GlobalConfigManagerState,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ pub async fn get_config(state: tauri::State<'_, AppState>) -> Result<config::Con
|
||||
#[tauri::command]
|
||||
pub async fn save_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config_manager: tauri::State<'_, GlobalConfigManagerState>,
|
||||
config: config::Config,
|
||||
) -> Result<(), String> {
|
||||
let host = config.server.host.to_lowercase();
|
||||
@@ -46,17 +47,30 @@ pub async fn save_config(
|
||||
return Err("安全限制:不允许开启远程管理功能".to_string());
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.config = config.clone();
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.config = config.clone();
|
||||
}
|
||||
|
||||
match config::save_config(&config) {
|
||||
let save_result = config::save_config(&config).map_err(|e| e.to_string());
|
||||
match save_result {
|
||||
Ok(()) => {
|
||||
let full_reload_event = ConfigChangeEvent::FullReload(FullReloadEvent {
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis() as u64,
|
||||
source: ConfigChangeSource::FrontendUI,
|
||||
});
|
||||
config_manager
|
||||
.0
|
||||
.subject()
|
||||
.notify_event(full_reload_event)
|
||||
.await;
|
||||
|
||||
tracing::info!("[CONFIG] 配置保存成功: host={}", config.server.host);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[CONFIG] 配置保存失败: {}", e);
|
||||
Err(e.to_string())
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
|
||||
use crate::app::types::LogState;
|
||||
use crate::logger;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// 前端异常上报参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FrontendCrashReport {
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stack: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub component: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_step: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 获取日志
|
||||
#[tauri::command]
|
||||
@@ -17,3 +37,74 @@ pub async fn clear_logs(logs: tauri::State<'_, LogState>) -> Result<(), String>
|
||||
logs.write().await.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写入前端异常到本地日志并同步到崩溃上报后端
|
||||
#[tauri::command]
|
||||
pub async fn report_frontend_crash(
|
||||
logs: tauri::State<'_, LogState>,
|
||||
report: FrontendCrashReport,
|
||||
) -> Result<(), String> {
|
||||
let sanitized_message = logger::sanitize_log_message(&report.message);
|
||||
let sanitized_component = report
|
||||
.component
|
||||
.as_deref()
|
||||
.map(logger::sanitize_log_message)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let sanitized_step = report
|
||||
.workflow_step
|
||||
.as_deref()
|
||||
.map(logger::sanitize_log_message)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let sanitized_mode = report
|
||||
.creation_mode
|
||||
.as_deref()
|
||||
.map(logger::sanitize_log_message)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let stack_preview = report
|
||||
.stack
|
||||
.as_deref()
|
||||
.map(logger::sanitize_log_message)
|
||||
.map(|stack| stack.lines().take(3).collect::<Vec<_>>().join(" | "))
|
||||
.unwrap_or_default();
|
||||
|
||||
logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[FrontendCrash] component={sanitized_component} step={sanitized_step} mode={sanitized_mode} message={sanitized_message} stack={stack_preview}"
|
||||
),
|
||||
);
|
||||
|
||||
let mut merged_context = match report.context {
|
||||
Some(Value::Object(context)) => context,
|
||||
Some(other) => {
|
||||
let mut context = Map::new();
|
||||
context.insert("raw_context".to_string(), other);
|
||||
context
|
||||
}
|
||||
None => Map::new(),
|
||||
};
|
||||
|
||||
if let Some(name) = report.name.as_deref() {
|
||||
merged_context.insert(
|
||||
"error_name".to_string(),
|
||||
Value::String(logger::sanitize_log_message(name)),
|
||||
);
|
||||
}
|
||||
if let Some(stack) = report.stack.as_deref() {
|
||||
merged_context.insert(
|
||||
"error_stack".to_string(),
|
||||
Value::String(logger::sanitize_log_message(stack)),
|
||||
);
|
||||
}
|
||||
|
||||
crate::crash_reporting::capture_frontend_report(
|
||||
&sanitized_message,
|
||||
report.component.as_deref(),
|
||||
report.workflow_step.as_deref(),
|
||||
report.creation_mode.as_deref(),
|
||||
Some(Value::Object(merged_context)),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ pub fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化崩溃上报(保持 guard 生命周期直到应用退出)
|
||||
let _crash_reporting_guard = crate::crash_reporting::init_from_config(&config);
|
||||
|
||||
// 初始化所有应用状态
|
||||
let states = match bootstrap::init_states(&config) {
|
||||
Ok(s) => s,
|
||||
@@ -765,6 +768,7 @@ pub fn run() {
|
||||
// Log commands (from app::commands)
|
||||
app_commands::get_logs,
|
||||
app_commands::clear_logs,
|
||||
app_commands::report_frontend_crash,
|
||||
// API test commands (from app::commands)
|
||||
app_commands::test_api,
|
||||
app_commands::get_available_models,
|
||||
@@ -1301,6 +1305,9 @@ pub fn run() {
|
||||
commands::material_cmd::get_material_content,
|
||||
commands::material_cmd::get_material_count,
|
||||
commands::material_cmd::get_materials_content,
|
||||
// Image search commands
|
||||
commands::image_search_cmd::search_pixabay_images,
|
||||
commands::image_search_cmd::search_web_images,
|
||||
// Video generation commands
|
||||
commands::video_generation_cmd::create_video_generation_task,
|
||||
commands::video_generation_cmd::get_video_generation_task,
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
//! 图片搜索相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供在线图片搜索功能,支持 Pixabay API 和联网搜索(Pexels)。
|
||||
|
||||
use crate::app::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
/// Pixabay 搜索请求
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PixabaySearchRequest {
|
||||
/// 搜索关键词
|
||||
pub query: String,
|
||||
/// 页码(从 1 开始)
|
||||
pub page: u32,
|
||||
/// 每页数量
|
||||
#[serde(alias = "per_page")]
|
||||
pub per_page: u32,
|
||||
/// 方向筛选:horizontal, vertical
|
||||
pub orientation: Option<String>,
|
||||
}
|
||||
|
||||
/// Pixabay 搜索响应
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PixabaySearchResponse {
|
||||
/// 总结果数
|
||||
pub total: u32,
|
||||
/// 总命中数
|
||||
pub total_hits: u32,
|
||||
/// 搜索结果
|
||||
pub hits: Vec<PixabayHit>,
|
||||
}
|
||||
|
||||
/// Pixabay 图片信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PixabayHit {
|
||||
/// 图片 ID
|
||||
pub id: u64,
|
||||
/// 预览图 URL
|
||||
pub preview_url: String,
|
||||
/// 大图 URL
|
||||
pub large_image_url: String,
|
||||
/// 图片宽度
|
||||
pub image_width: u32,
|
||||
/// 图片高度
|
||||
pub image_height: u32,
|
||||
/// 标签(逗号分隔)
|
||||
pub tags: String,
|
||||
/// 来源页面 URL
|
||||
pub page_url: String,
|
||||
/// 作者名称
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// 联网图片搜索请求
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebImageSearchRequest {
|
||||
/// 搜索关键词
|
||||
pub query: String,
|
||||
/// 页码(从 1 开始)
|
||||
pub page: u32,
|
||||
/// 每页数量
|
||||
#[serde(alias = "per_page")]
|
||||
pub per_page: u32,
|
||||
/// 方向筛选:landscape, portrait, square
|
||||
pub aspect: Option<String>,
|
||||
}
|
||||
|
||||
/// 联网图片搜索响应(统一格式)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebImageSearchResponse {
|
||||
/// 总结果数
|
||||
pub total: u32,
|
||||
/// 搜索结果
|
||||
pub hits: Vec<WebImageHit>,
|
||||
/// 来源提供者标识
|
||||
pub provider: String,
|
||||
}
|
||||
|
||||
/// 联网图片信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebImageHit {
|
||||
/// 图片 ID
|
||||
pub id: String,
|
||||
/// 缩略图 URL
|
||||
pub thumbnail_url: String,
|
||||
/// 原图 URL
|
||||
pub content_url: String,
|
||||
/// 图片宽度
|
||||
pub width: u32,
|
||||
/// 图片高度
|
||||
pub height: u32,
|
||||
/// 标题/描述
|
||||
pub name: String,
|
||||
/// 来源页面 URL
|
||||
pub host_page_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PexelsSearchResponse {
|
||||
total_results: u32,
|
||||
photos: Vec<PexelsPhoto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PexelsPhoto {
|
||||
id: u64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
url: String,
|
||||
alt: Option<String>,
|
||||
src: PexelsPhotoSrc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct PexelsPhotoSrc {
|
||||
tiny: Option<String>,
|
||||
small: Option<String>,
|
||||
medium: Option<String>,
|
||||
large: Option<String>,
|
||||
large2x: Option<String>,
|
||||
landscape: Option<String>,
|
||||
portrait: Option<String>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取 Pixabay API Key(优先配置,其次环境变量)
|
||||
async fn get_pixabay_api_key(app_state: State<'_, AppState>) -> Option<String> {
|
||||
let key_from_config = {
|
||||
let state = app_state.read().await;
|
||||
state.config.image_gen.image_search_pixabay_api_key.clone()
|
||||
};
|
||||
|
||||
key_from_config
|
||||
.and_then(|key| {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("PIXABAY_API_KEY").ok().and_then(|key| {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Pexels API Key(优先配置,其次环境变量)
|
||||
async fn get_pexels_api_key(app_state: State<'_, AppState>) -> Option<String> {
|
||||
let key_from_config = {
|
||||
let state = app_state.read().await;
|
||||
state.config.image_gen.image_search_pexels_api_key.clone()
|
||||
};
|
||||
|
||||
key_from_config
|
||||
.and_then(|key| {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("PEXELS_API_KEY").ok().and_then(|key| {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn map_aspect_to_pexels_orientation(aspect: Option<&str>) -> Option<&'static str> {
|
||||
match aspect.unwrap_or_default() {
|
||||
"landscape" => Some("landscape"),
|
||||
"portrait" => Some("portrait"),
|
||||
"square" => Some("square"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_pexels_to_web_response(resp: PexelsSearchResponse) -> WebImageSearchResponse {
|
||||
let hits = resp
|
||||
.photos
|
||||
.into_iter()
|
||||
.filter_map(|photo| {
|
||||
let content_url = photo
|
||||
.src
|
||||
.large2x
|
||||
.clone()
|
||||
.or(photo.src.large.clone())
|
||||
.or(photo.src.original.clone())
|
||||
.or(photo.src.landscape.clone())
|
||||
.or(photo.src.portrait.clone())
|
||||
.or(photo.src.medium.clone())
|
||||
.or(photo.src.small.clone())
|
||||
.or(photo.src.tiny.clone())?;
|
||||
let thumbnail_url = photo
|
||||
.src
|
||||
.medium
|
||||
.clone()
|
||||
.or(photo.src.small.clone())
|
||||
.or(photo.src.tiny.clone())
|
||||
.or(photo.src.landscape.clone())
|
||||
.or(photo.src.portrait.clone())
|
||||
.or(photo.src.large.clone())
|
||||
.or(photo.src.original.clone())
|
||||
.unwrap_or_else(|| content_url.clone());
|
||||
let name = photo
|
||||
.alt
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "Pexels Image".to_string());
|
||||
|
||||
Some(WebImageHit {
|
||||
id: photo.id.to_string(),
|
||||
thumbnail_url,
|
||||
content_url,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
name,
|
||||
host_page_url: photo.url,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
WebImageSearchResponse {
|
||||
total: resp.total_results,
|
||||
hits,
|
||||
provider: "pexels".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索 Pixabay 图片
|
||||
///
|
||||
/// 通过 Pixabay API 搜索在线图片。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `req`: 搜索请求,包含关键词、页码、每页数量等
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回搜索结果
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn search_pixabay_images(
|
||||
app_state: State<'_, AppState>,
|
||||
req: PixabaySearchRequest,
|
||||
) -> Result<PixabaySearchResponse, String> {
|
||||
let api_key = get_pixabay_api_key(app_state)
|
||||
.await
|
||||
.ok_or_else(|| "未配置 Pixabay API Key,请先在设置 → 系统 → 网络搜索中配置".to_string())?;
|
||||
|
||||
// 构建 URL
|
||||
let url = format!(
|
||||
"https://pixabay.com/api/?key={}&q={}&page={}&per_page={}&image_type=photo&safesearch=true",
|
||||
api_key,
|
||||
urlencoding::encode(&req.query),
|
||||
req.page,
|
||||
req.per_page
|
||||
);
|
||||
|
||||
// 添加 orientation 参数
|
||||
let url = if let Some(orientation) = &req.orientation {
|
||||
if !orientation.is_empty() {
|
||||
format!("{}&orientation={}", url, orientation)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
} else {
|
||||
url
|
||||
};
|
||||
|
||||
// 发起请求
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|error| error.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| body.chars().take(120).collect::<String>());
|
||||
return Err(format!("Pixabay API 返回错误: HTTP {} {}", status, detail));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {}", e))?;
|
||||
|
||||
// 映射响应
|
||||
let total = json["total"].as_u64().map(|v| v as u32).unwrap_or(0);
|
||||
let total_hits = json["totalHits"].as_u64().map(|v| v as u32).unwrap_or(0);
|
||||
|
||||
let hits = json["hits"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|hit| {
|
||||
Some(PixabayHit {
|
||||
id: hit["id"].as_u64()? as u64,
|
||||
preview_url: hit["previewURL"].as_str()?.to_string(),
|
||||
large_image_url: hit["largeImageURL"].as_str()?.to_string(),
|
||||
image_width: hit["imageWidth"].as_u64()? as u32,
|
||||
image_height: hit["imageHeight"].as_u64()? as u32,
|
||||
tags: hit["tags"].as_str().unwrap_or("").to_string(),
|
||||
page_url: hit["pageURL"].as_str().unwrap_or("").to_string(),
|
||||
user: hit["user"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(PixabaySearchResponse {
|
||||
total,
|
||||
total_hits,
|
||||
hits,
|
||||
})
|
||||
}
|
||||
|
||||
/// 联网搜索图片(Pexels)
|
||||
#[tauri::command]
|
||||
pub async fn search_web_images(
|
||||
app_state: State<'_, AppState>,
|
||||
req: WebImageSearchRequest,
|
||||
) -> Result<WebImageSearchResponse, String> {
|
||||
let api_key = get_pexels_api_key(app_state)
|
||||
.await
|
||||
.ok_or_else(|| "未配置 Pexels API Key,请先在设置 → 系统 → 网络搜索中配置".to_string())?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut query = vec![
|
||||
("query", req.query.clone()),
|
||||
("page", req.page.to_string()),
|
||||
("per_page", req.per_page.to_string()),
|
||||
];
|
||||
|
||||
if let Some(orientation) = map_aspect_to_pexels_orientation(req.aspect.as_deref()) {
|
||||
query.push(("orientation", orientation.to_string()));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get("https://api.pexels.com/v1/search")
|
||||
.header("Authorization", api_key)
|
||||
.header(
|
||||
reqwest::header::USER_AGENT,
|
||||
"Mozilla/5.0 (compatible; ProxyCast/0.75; +https://github.com/aiclientproxy/proxycast)",
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 Pexels 失败: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
return Err("Pexels API Key 无效或无权限,请检查设置".to_string());
|
||||
}
|
||||
return Err(format!("Pexels API 返回错误: HTTP {status}"));
|
||||
}
|
||||
|
||||
let body: PexelsSearchResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 Pexels 响应失败: {e}"))?;
|
||||
|
||||
Ok(map_pexels_to_web_response(body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pixabay_request_serialization() {
|
||||
let req = PixabaySearchRequest {
|
||||
query: "nature".to_string(),
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
orientation: Some("horizontal".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("nature"));
|
||||
assert!(json.contains("horizontal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_web_request_serialization() {
|
||||
let req = WebImageSearchRequest {
|
||||
query: "city".to_string(),
|
||||
page: 2,
|
||||
per_page: 30,
|
||||
aspect: Some("landscape".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("city"));
|
||||
assert!(json.contains("landscape"));
|
||||
assert!(json.contains("\"page\":2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_aspect_to_pexels_orientation() {
|
||||
assert_eq!(
|
||||
map_aspect_to_pexels_orientation(Some("landscape")),
|
||||
Some("landscape")
|
||||
);
|
||||
assert_eq!(
|
||||
map_aspect_to_pexels_orientation(Some("portrait")),
|
||||
Some("portrait")
|
||||
);
|
||||
assert_eq!(
|
||||
map_aspect_to_pexels_orientation(Some("square")),
|
||||
Some("square")
|
||||
);
|
||||
assert_eq!(map_aspect_to_pexels_orientation(Some("all")), None);
|
||||
assert_eq!(map_aspect_to_pexels_orientation(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_pexels_to_web_response() {
|
||||
let resp = PexelsSearchResponse {
|
||||
total_results: 2,
|
||||
photos: vec![PexelsPhoto {
|
||||
id: 123,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
url: "https://www.pexels.com/photo/test".to_string(),
|
||||
alt: Some(" 城市夜景 ".to_string()),
|
||||
src: PexelsPhotoSrc {
|
||||
tiny: None,
|
||||
small: None,
|
||||
medium: Some("https://images.pexels.com/medium.jpg".to_string()),
|
||||
large: Some("https://images.pexels.com/large.jpg".to_string()),
|
||||
large2x: Some("https://images.pexels.com/large2x.jpg".to_string()),
|
||||
landscape: None,
|
||||
portrait: None,
|
||||
original: None,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let mapped = map_pexels_to_web_response(resp);
|
||||
assert_eq!(mapped.provider, "pexels");
|
||||
assert_eq!(mapped.total, 2);
|
||||
assert_eq!(mapped.hits.len(), 1);
|
||||
let hit = &mapped.hits[0];
|
||||
assert_eq!(hit.id, "123");
|
||||
assert_eq!(hit.name, "城市夜景");
|
||||
assert_eq!(hit.content_url, "https://images.pexels.com/large2x.jpg");
|
||||
assert_eq!(hit.thumbnail_url, "https://images.pexels.com/medium.jpg");
|
||||
assert_eq!(hit.width, 1920);
|
||||
assert_eq!(hit.height, 1080);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod external_tools_cmd;
|
||||
pub mod file_upload_cmd;
|
||||
pub mod general_chat_cmd;
|
||||
pub mod heartbeat_cmd;
|
||||
pub mod image_search_cmd;
|
||||
pub mod injection_cmd;
|
||||
pub mod kiro_local;
|
||||
pub mod machine_id_cmd;
|
||||
|
||||
@@ -8,7 +8,8 @@ mod tauri_observer;
|
||||
|
||||
// 从 proxycast-config crate 重新导出被使用的类型
|
||||
pub use proxycast_config::observer::events::{
|
||||
ConfigChangeEvent, ConfigChangeSource, EndpointProvidersChangeEvent, RoutingChangeEvent,
|
||||
ConfigChangeEvent, ConfigChangeSource, EndpointProvidersChangeEvent, FullReloadEvent,
|
||||
RoutingChangeEvent,
|
||||
};
|
||||
|
||||
// Tauri 相关实现
|
||||
|
||||
@@ -205,6 +205,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
|
||||
pairing: proxycast_core::config::PairingSettings::default(),
|
||||
heartbeat: proxycast_core::config::HeartbeatSettings::default(),
|
||||
channels: proxycast_core::config::ChannelsConfig::default(),
|
||||
crash_reporting: proxycast_core::config::CrashReportingConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -458,6 +459,7 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
|
||||
pairing: proxycast_core::config::PairingSettings::default(),
|
||||
heartbeat: proxycast_core::config::HeartbeatSettings::default(),
|
||||
channels: proxycast_core::config::ChannelsConfig::default(),
|
||||
crash_reporting: proxycast_core::config::CrashReportingConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -522,6 +524,7 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
|
||||
pairing: proxycast_core::config::PairingSettings::default(),
|
||||
heartbeat: proxycast_core::config::HeartbeatSettings::default(),
|
||||
channels: proxycast_core::config::ChannelsConfig::default(),
|
||||
crash_reporting: proxycast_core::config::CrashReportingConfig::default(),
|
||||
};
|
||||
// 根据类型使配置无效
|
||||
match invalid_type {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 崩溃上报初始化与上报辅助(Sentry 协议兼容)
|
||||
|
||||
use proxycast_core::config::{Config, CrashReportingConfig};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 根据配置初始化 Sentry 客户端
|
||||
pub fn init_from_config(config: &Config) -> Option<sentry::ClientInitGuard> {
|
||||
init(&config.crash_reporting)
|
||||
}
|
||||
|
||||
fn init(config: &CrashReportingConfig) -> Option<sentry::ClientInitGuard> {
|
||||
if !config.enabled {
|
||||
tracing::info!("[CrashReporting] 已禁用崩溃上报");
|
||||
return None;
|
||||
}
|
||||
|
||||
let dsn = config
|
||||
.dsn
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let sample_rate = config.sample_rate.clamp(0.0, 1.0) as f32;
|
||||
let environment = config.environment.trim();
|
||||
let environment = if environment.is_empty() {
|
||||
"production"
|
||||
} else {
|
||||
environment
|
||||
};
|
||||
|
||||
let guard = sentry::init((
|
||||
dsn,
|
||||
sentry::ClientOptions {
|
||||
release: sentry::release_name!(),
|
||||
environment: Some(environment.to_string().into()),
|
||||
sample_rate,
|
||||
send_default_pii: config.send_pii,
|
||||
attach_stacktrace: true,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
|
||||
sentry::configure_scope(|scope| {
|
||||
scope.set_tag("platform", std::env::consts::OS);
|
||||
scope.set_tag("arch", std::env::consts::ARCH);
|
||||
scope.set_tag("app", "proxycast");
|
||||
scope.set_tag("app_version", env!("CARGO_PKG_VERSION"));
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
"[CrashReporting] Sentry 已初始化: env={}, sample_rate={}",
|
||||
environment,
|
||||
sample_rate
|
||||
);
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
/// 记录来自前端的崩溃/异常事件(在已初始化时发送到 Sentry)
|
||||
pub fn capture_frontend_report(
|
||||
message: &str,
|
||||
component: Option<&str>,
|
||||
workflow_step: Option<&str>,
|
||||
creation_mode: Option<&str>,
|
||||
metadata: Option<Value>,
|
||||
) {
|
||||
if message.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sentry::with_scope(
|
||||
|scope| {
|
||||
scope.set_tag("origin", "frontend");
|
||||
if let Some(component_name) = component.filter(|value| !value.trim().is_empty()) {
|
||||
scope.set_tag("component", component_name.to_string());
|
||||
}
|
||||
if let Some(step) = workflow_step.filter(|value| !value.trim().is_empty()) {
|
||||
scope.set_tag("workflow_step", step.to_string());
|
||||
}
|
||||
if let Some(mode) = creation_mode.filter(|value| !value.trim().is_empty()) {
|
||||
scope.set_tag("creation_mode", mode.to_string());
|
||||
}
|
||||
if let Some(extra) = metadata {
|
||||
scope.set_extra("frontend_report", sentry::protocol::Value::from(extra));
|
||||
}
|
||||
},
|
||||
|| {
|
||||
sentry::capture_message(message, sentry::Level::Error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub mod voice;
|
||||
// 内部模块
|
||||
mod commands;
|
||||
mod config;
|
||||
mod crash_reporting;
|
||||
mod data;
|
||||
#[cfg(debug_assertions)]
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.75.0",
|
||||
"version": "0.76.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AgentChatPage } from "./components/agent";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { ImageGenPage } from "./components/image-gen";
|
||||
import { BatchPage } from "./components/batch";
|
||||
import { RecentImageInsertFloating } from "./components/image-gen/RecentImageInsertFloating";
|
||||
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
|
||||
import { WorkbenchPage } from "./components/workspace";
|
||||
import {
|
||||
@@ -522,6 +523,7 @@ function AppContent() {
|
||||
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
|
||||
)}
|
||||
<MainContent>{renderAllPages()}</MainContent>
|
||||
<RecentImageInsertFloating onNavigate={handleNavigate} />
|
||||
|
||||
<ConnectConfirmDialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -274,7 +274,7 @@ const MAIN_MENU_ITEMS: SidebarNavItem[] = [
|
||||
params: { workspaceViewMode: "workspace" },
|
||||
isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"),
|
||||
},
|
||||
{ id: "image-gen", label: "绘画", icon: Image, page: "image-gen" },
|
||||
{ id: "image-gen", label: "插图", icon: Image, page: "image-gen" },
|
||||
{ id: "batch", label: "批量任务", icon: Layers, page: "batch" },
|
||||
{ id: "plugins", label: "插件中心", icon: Compass, page: "plugins" },
|
||||
];
|
||||
@@ -565,7 +565,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
? buildWorkspaceResetParams(
|
||||
item.params as AgentPageParams | undefined,
|
||||
(item.params as AgentPageParams | undefined)?.workspaceViewMode ??
|
||||
"project-management",
|
||||
"project-management",
|
||||
)
|
||||
: item.params;
|
||||
|
||||
|
||||
@@ -1503,7 +1503,6 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
console.log("[useAgentChat] 转换后消息数量:", loadedMessages.length);
|
||||
setMessages(loadedMessages);
|
||||
setSessionId(topicId);
|
||||
toast.info("已切换话题");
|
||||
} catch (error) {
|
||||
if (restoreRequestVersion !== sessionResetVersionRef.current) {
|
||||
console.log("[useAgentChat] 忽略过期会话切换错误:", topicId);
|
||||
|
||||
@@ -2268,7 +2268,6 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
|
||||
topicPreference.model,
|
||||
);
|
||||
}
|
||||
toast.info("已切换话题");
|
||||
} catch (error) {
|
||||
console.error("[AsterChat] 切换话题失败:", error);
|
||||
console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2));
|
||||
|
||||
@@ -70,6 +70,7 @@ import { SettingsTabs } from "@/types/settings";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge";
|
||||
import { setActiveContentTarget } from "@/lib/activeContentTarget";
|
||||
|
||||
import type { MessageImage } from "./types";
|
||||
import type {
|
||||
@@ -318,6 +319,10 @@ export function AgentChatPage({
|
||||
const [novelChapterListCollapsed, setNovelChapterListCollapsed] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveContentTarget(projectId, contentId, canvasState?.type ?? null);
|
||||
}, [canvasState?.type, contentId, projectId]);
|
||||
|
||||
// General 主题专用画布状态
|
||||
const [generalCanvasState, setGeneralCanvasState] =
|
||||
useState<GeneralCanvasState>(DEFAULT_CANVAS_STATE);
|
||||
@@ -2037,6 +2042,9 @@ export function AgentChatPage({
|
||||
onClose={handleCloseCanvas}
|
||||
isStreaming={isSending}
|
||||
onSelectionTextChange={handleCanvasSelectionTextChange}
|
||||
projectId={projectId ?? null}
|
||||
contentId={contentId ?? null}
|
||||
autoImageTopic={project?.name || undefined}
|
||||
novelControls={
|
||||
canvasState.type === "novel"
|
||||
? {
|
||||
@@ -2057,6 +2065,9 @@ export function AgentChatPage({
|
||||
generalCanvasState,
|
||||
canvasState,
|
||||
mappedTheme,
|
||||
project,
|
||||
projectId,
|
||||
contentId,
|
||||
handleCloseCanvas,
|
||||
handleBackHome,
|
||||
isSending,
|
||||
|
||||
@@ -63,8 +63,10 @@ function getLanguageExtension(language: string): string {
|
||||
};
|
||||
|
||||
const lower = language.toLowerCase();
|
||||
// 使用 in 操作符检查,避免原型链上的属性
|
||||
return lower in langExtMap ? langExtMap[lower] : "txt";
|
||||
// 使用 hasOwnProperty 检查,避免原型链上的属性污染
|
||||
return Object.prototype.hasOwnProperty.call(langExtMap, lower)
|
||||
? langExtMap[lower]
|
||||
: "txt";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -235,8 +235,10 @@ function getLanguageExtension(language: string): string {
|
||||
};
|
||||
|
||||
const lower = language.toLowerCase();
|
||||
// 使用 in 操作符检查,避免原型链上的属性
|
||||
return lower in langExtMap ? langExtMap[lower] : "txt";
|
||||
// 使用 hasOwnProperty 检查,避免原型链上的属性污染
|
||||
return Object.prototype.hasOwnProperty.call(langExtMap, lower)
|
||||
? langExtMap[lower]
|
||||
: "txt";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,12 @@ interface CanvasFactoryProps {
|
||||
} | null;
|
||||
/** 画布选中文本变更 */
|
||||
onSelectionTextChange?: (text: string) => void;
|
||||
/** 当前项目 ID(用于跨页面插图) */
|
||||
projectId?: string | null;
|
||||
/** 当前文稿 ID(用于跨页面插图) */
|
||||
contentId?: string | null;
|
||||
/** 自动配图主题关键词 */
|
||||
autoImageTopic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +71,9 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
isStreaming,
|
||||
novelControls,
|
||||
onSelectionTextChange,
|
||||
projectId,
|
||||
contentId,
|
||||
autoImageTopic,
|
||||
}) => {
|
||||
const resolvedBackHome = onBackHome ?? onClose;
|
||||
|
||||
@@ -89,6 +98,9 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onClose={onClose}
|
||||
isStreaming={isStreaming}
|
||||
onSelectionTextChange={onSelectionTextChange}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
autoImageTopic={autoImageTopic}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +112,8 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onStateChange={onStateChange as (s: PosterCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +126,8 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
isStreaming={isStreaming}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +139,8 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onStateChange={onStateChange as (s: ScriptCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +152,8 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onStateChange={onStateChange as (s: NovelCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
useExternalToolbar={novelControls?.useExternalToolbar}
|
||||
chapterListCollapsed={novelControls?.chapterListCollapsed}
|
||||
onChapterListCollapsedChange={
|
||||
@@ -151,6 +171,8 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
onStateChange={onStateChange as (s: VideoCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
projectId={projectId}
|
||||
contentId={contentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ThemeType } from "../types";
|
||||
|
||||
describe("getCanvasTypeForTheme", () => {
|
||||
it("应该为每种主题返回正确的画布类型", () => {
|
||||
expect(getCanvasTypeForTheme("video")).toBe("script");
|
||||
expect(getCanvasTypeForTheme("video")).toBe("video");
|
||||
expect(getCanvasTypeForTheme("novel")).toBe("novel");
|
||||
expect(getCanvasTypeForTheme("poster")).toBe("poster");
|
||||
expect(getCanvasTypeForTheme("music")).toBe("music");
|
||||
@@ -102,9 +102,9 @@ describe("createInitialCanvasState", () => {
|
||||
expect(novelState).not.toBeNull();
|
||||
expect(novelState?.type).toBe("novel");
|
||||
|
||||
const scriptState = createInitialCanvasState("video", "test content");
|
||||
expect(scriptState).not.toBeNull();
|
||||
expect(scriptState?.type).toBe("script");
|
||||
const videoState = createInitialCanvasState("video", "test content");
|
||||
expect(videoState).not.toBeNull();
|
||||
expect(videoState?.type).toBe("video");
|
||||
|
||||
const posterState = createInitialCanvasState("poster");
|
||||
expect(posterState).not.toBeNull();
|
||||
|
||||
@@ -6,12 +6,57 @@
|
||||
|
||||
import React, { memo, useMemo, useCallback, useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types";
|
||||
import { DocumentToolbar } from "./DocumentToolbar";
|
||||
import { DocumentRenderer } from "./DocumentRenderer";
|
||||
import { NotionEditor } from "./editor";
|
||||
import { PlatformTabs } from "./PlatformTabs";
|
||||
import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
type InsertableImage,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
import {
|
||||
applySectionImageAssignments,
|
||||
appendImageToMarkdown,
|
||||
buildSectionSearchQuery,
|
||||
extractLevel2Sections,
|
||||
} from "./utils/autoImageInsert";
|
||||
|
||||
interface WebImageSearchResponse {
|
||||
total: number;
|
||||
provider: string;
|
||||
hits: Array<{
|
||||
id: string;
|
||||
thumbnail_url?: string;
|
||||
content_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
name?: string;
|
||||
host_page_url?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PixabaySearchResponse {
|
||||
total: number;
|
||||
total_hits?: number;
|
||||
hits: Array<{
|
||||
id: number;
|
||||
preview_url?: string;
|
||||
large_image_url?: string;
|
||||
image_width?: number;
|
||||
image_height?: number;
|
||||
tags?: string;
|
||||
page_url?: string;
|
||||
user?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -69,10 +114,18 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
onClose,
|
||||
isStreaming = false,
|
||||
onSelectionTextChange,
|
||||
projectId,
|
||||
contentId,
|
||||
autoImageTopic,
|
||||
}) => {
|
||||
const [editingContent, setEditingContent] = useState("");
|
||||
const [toastMessage, setToastMessage] = useState("");
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [autoInsertLoading, setAutoInsertLoading] = useState(false);
|
||||
const [pendingEditorInsert, setPendingEditorInsert] = useState<{
|
||||
requestId: string;
|
||||
image: InsertableImage;
|
||||
} | null>(null);
|
||||
|
||||
// 当前版本
|
||||
const currentVersion = useMemo(() => {
|
||||
@@ -92,6 +145,255 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
setTimeout(() => setShowToast(false), 2000);
|
||||
}, []);
|
||||
|
||||
const appendImageIntoDocument = useCallback(
|
||||
(image: InsertableImage, description = "插入图片") => {
|
||||
const baseContent = state.content;
|
||||
const nextContent = appendImageToMarkdown(baseContent, image, true);
|
||||
if (nextContent === baseContent) {
|
||||
showMessage("ℹ️ 图片已存在,跳过插入");
|
||||
return false;
|
||||
}
|
||||
|
||||
const newVersion = {
|
||||
id: crypto.randomUUID(),
|
||||
content: nextContent,
|
||||
createdAt: Date.now(),
|
||||
description,
|
||||
};
|
||||
onStateChange({
|
||||
...state,
|
||||
content: nextContent,
|
||||
versions: [...state.versions, newVersion],
|
||||
currentVersionId: newVersion.id,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[onStateChange, showMessage, state],
|
||||
);
|
||||
|
||||
const matchesRequestTarget = useCallback(
|
||||
(request: CanvasImageInsertRequest): boolean =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "document",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
const processInsertRequest = useCallback(
|
||||
(request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isEditing) {
|
||||
setPendingEditorInsert({
|
||||
requestId: request.requestId,
|
||||
image: request.image,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = appendImageIntoDocument(request.image, "手动插图");
|
||||
if (inserted) {
|
||||
showMessage("🖼️ 已插入文稿");
|
||||
}
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: inserted,
|
||||
canvasType: "document",
|
||||
locationLabel: inserted ? "文档正文末尾" : "文档中已存在同图",
|
||||
reason: inserted ? undefined : "duplicate",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
},
|
||||
[appendImageIntoDocument, matchesRequestTarget, showMessage, state.isEditing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingRequests = getPendingCanvasImageInsertRequests();
|
||||
pendingRequests.forEach((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
}, [processInsertRequest]);
|
||||
|
||||
const mapWebHitToInsertable = useCallback(
|
||||
(hit: WebImageSearchResponse["hits"][number], provider: string) => {
|
||||
const contentUrl = hit.content_url || hit.thumbnail_url || "";
|
||||
const previewUrl = hit.thumbnail_url || hit.content_url || "";
|
||||
if (!contentUrl || !previewUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: hit.id || crypto.randomUUID(),
|
||||
previewUrl,
|
||||
contentUrl,
|
||||
pageUrl: hit.host_page_url,
|
||||
title: hit.name || "插图",
|
||||
width: hit.width,
|
||||
height: hit.height,
|
||||
attributionName: provider || "Pexels",
|
||||
provider,
|
||||
} as InsertableImage;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mapPixabayHitToInsertable = useCallback(
|
||||
(hit: PixabaySearchResponse["hits"][number]) => {
|
||||
const contentUrl = hit.large_image_url || hit.preview_url || "";
|
||||
const previewUrl = hit.preview_url || hit.large_image_url || "";
|
||||
if (!contentUrl || !previewUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(hit.id || crypto.randomUUID()),
|
||||
previewUrl,
|
||||
contentUrl,
|
||||
pageUrl: hit.page_url,
|
||||
title: hit.tags || "插图",
|
||||
width: hit.image_width,
|
||||
height: hit.image_height,
|
||||
attributionName: "Pixabay",
|
||||
provider: "pixabay",
|
||||
} as InsertableImage;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const searchImageWithFallback = useCallback(
|
||||
async (query: string): Promise<InsertableImage | null> => {
|
||||
if (!query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const webResp = await invoke<WebImageSearchResponse>("search_web_images", {
|
||||
req: {
|
||||
query,
|
||||
page: 1,
|
||||
perPage: 6,
|
||||
},
|
||||
});
|
||||
const fromWeb = webResp.hits
|
||||
.map((hit) => mapWebHitToInsertable(hit, webResp.provider || "pexels"))
|
||||
.find(Boolean);
|
||||
if (fromWeb) {
|
||||
return fromWeb;
|
||||
}
|
||||
} catch {
|
||||
// 回退到 Pixabay
|
||||
}
|
||||
|
||||
try {
|
||||
const pixabayResp = await invoke<PixabaySearchResponse>(
|
||||
"search_pixabay_images",
|
||||
{
|
||||
req: {
|
||||
query,
|
||||
page: 1,
|
||||
perPage: 6,
|
||||
},
|
||||
},
|
||||
);
|
||||
const fromPixabay = pixabayResp.hits
|
||||
.map((hit) => mapPixabayHitToInsertable(hit))
|
||||
.find(Boolean);
|
||||
return fromPixabay || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[mapPixabayHitToInsertable, mapWebHitToInsertable],
|
||||
);
|
||||
|
||||
const handleAutoInsertImages = useCallback(async () => {
|
||||
if (autoInsertLoading) {
|
||||
return;
|
||||
}
|
||||
if (state.isEditing) {
|
||||
showMessage("ℹ️ 请先保存当前编辑,再执行主题配图");
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoInsertLoading(true);
|
||||
try {
|
||||
const baseContent = state.content;
|
||||
const sections = extractLevel2Sections(baseContent).slice(0, 6);
|
||||
const sectionTitles =
|
||||
sections.length > 0
|
||||
? sections.map((section) => section.title)
|
||||
: [autoImageTopic || "文稿主题"];
|
||||
|
||||
const assignments: Array<{
|
||||
sectionTitle: string;
|
||||
image: InsertableImage;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < sectionTitles.length; index += 1) {
|
||||
const sectionTitle = sectionTitles[index];
|
||||
const query = buildSectionSearchQuery(autoImageTopic, sectionTitle);
|
||||
if (!query) {
|
||||
continue;
|
||||
}
|
||||
showMessage(`🖼️ 正在匹配配图 ${index + 1}/${sectionTitles.length}`);
|
||||
const image = await searchImageWithFallback(query);
|
||||
if (image) {
|
||||
assignments.push({
|
||||
sectionTitle,
|
||||
image,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!assignments.length) {
|
||||
showMessage("⚠️ 未找到可用图片,建议手动插图");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContent = applySectionImageAssignments(baseContent, assignments, {
|
||||
includeAttribution: true,
|
||||
});
|
||||
|
||||
if (nextContent === baseContent) {
|
||||
showMessage("ℹ️ 当前小节已有图片,未重复插入");
|
||||
return;
|
||||
}
|
||||
|
||||
const newVersion = {
|
||||
id: crypto.randomUUID(),
|
||||
content: nextContent,
|
||||
createdAt: Date.now(),
|
||||
description: "主题自动配图",
|
||||
};
|
||||
onStateChange({
|
||||
...state,
|
||||
content: nextContent,
|
||||
versions: [...state.versions, newVersion],
|
||||
currentVersionId: newVersion.id,
|
||||
});
|
||||
showMessage(`✅ 自动配图完成,已插入 ${assignments.length} 张`);
|
||||
} finally {
|
||||
setAutoInsertLoading(false);
|
||||
}
|
||||
}, [
|
||||
autoImageTopic,
|
||||
autoInsertLoading,
|
||||
onStateChange,
|
||||
searchImageWithFallback,
|
||||
showMessage,
|
||||
state,
|
||||
]);
|
||||
|
||||
// 切换版本
|
||||
const handleVersionChange = useCallback(
|
||||
(versionId: string) => {
|
||||
@@ -208,6 +510,8 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onExport={handleExport}
|
||||
onAutoInsertImages={handleAutoInsertImages}
|
||||
autoInsertLoading={autoInsertLoading}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -219,6 +523,33 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onSelectionTextChange={onSelectionTextChange}
|
||||
externalImageInsert={
|
||||
pendingEditorInsert
|
||||
? {
|
||||
requestId: pendingEditorInsert.requestId,
|
||||
url: pendingEditorInsert.image.contentUrl,
|
||||
alt: pendingEditorInsert.image.title || "插图",
|
||||
}
|
||||
: null
|
||||
}
|
||||
onExternalImageInsertComplete={(requestId, success) => {
|
||||
if (success) {
|
||||
showMessage("🖼️ 已插入文稿(编辑态)");
|
||||
} else {
|
||||
showMessage("⚠️ 插图失败,请重试");
|
||||
}
|
||||
emitCanvasImageInsertAck({
|
||||
requestId,
|
||||
success,
|
||||
canvasType: "document",
|
||||
locationLabel: success ? "文档编辑器当前光标位置" : undefined,
|
||||
reason: success ? undefined : "editor_insert_failed",
|
||||
});
|
||||
ackCanvasImageInsertRequest(requestId);
|
||||
setPendingEditorInsert((prev) =>
|
||||
prev?.requestId === requestId ? null : prev,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentRenderer
|
||||
|
||||
@@ -146,6 +146,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
|
||||
onSave,
|
||||
onCancel,
|
||||
onExport,
|
||||
onAutoInsertImages,
|
||||
autoInsertLoading = false,
|
||||
onClose,
|
||||
}) => {
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
@@ -191,6 +193,15 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = memo(
|
||||
) : (
|
||||
<>
|
||||
<ToolButton onClick={onEditToggle}>✏️ 编辑</ToolButton>
|
||||
{onAutoInsertImages && (
|
||||
<ToolButton
|
||||
onClick={onAutoInsertImages}
|
||||
disabled={autoInsertLoading}
|
||||
title="按主题自动搜索并插入图片"
|
||||
>
|
||||
{autoInsertLoading ? "🖼️ 配图中..." : "🖼️ 主题配图"}
|
||||
</ToolButton>
|
||||
)}
|
||||
<ExportDropdown ref={exportRef}>
|
||||
<ToolButton onClick={() => setShowExportMenu(!showExportMenu)}>
|
||||
📤 导出 ▼
|
||||
|
||||
@@ -23,6 +23,12 @@ interface NotionEditorProps {
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onSelectionTextChange?: (text: string) => void;
|
||||
externalImageInsert?: {
|
||||
requestId: string;
|
||||
url: string;
|
||||
alt?: string;
|
||||
} | null;
|
||||
onExternalImageInsertComplete?: (requestId: string, success: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SLASH: SlashMenuState = {
|
||||
@@ -33,9 +39,18 @@ const EMPTY_SLASH: SlashMenuState = {
|
||||
};
|
||||
|
||||
export const NotionEditor: React.FC<NotionEditorProps> = memo(
|
||||
({ content, onChange, onSave, onCancel, onSelectionTextChange }) => {
|
||||
({
|
||||
content,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onSelectionTextChange,
|
||||
externalImageInsert,
|
||||
onExternalImageInsertComplete,
|
||||
}) => {
|
||||
const [slashState, setSlashState] = useState<SlashMenuState>(EMPTY_SLASH);
|
||||
const keyDownRef = useRef<SlashMenuKeyHandler | null>(null);
|
||||
const handledExternalInsertRef = useRef<string | null>(null);
|
||||
|
||||
const extensions = useMemo(
|
||||
() =>
|
||||
@@ -113,6 +128,31 @@ export const NotionEditor: React.FC<NotionEditorProps> = memo(
|
||||
};
|
||||
}, [editor, onSelectionTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !externalImageInsert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledExternalInsertRef.current === externalImageInsert.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledExternalInsertRef.current = externalImageInsert.requestId;
|
||||
const success = editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: externalImageInsert.url,
|
||||
alt: externalImageInsert.alt || "插图",
|
||||
})
|
||||
.run();
|
||||
|
||||
if (success) {
|
||||
onChange(htmlToMarkdown(editor.getHTML()));
|
||||
}
|
||||
onExternalImageInsertComplete?.(externalImageInsert.requestId, success);
|
||||
}, [editor, externalImageInsert, onChange, onExternalImageInsertComplete]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface DocumentCanvasProps {
|
||||
isStreaming?: boolean;
|
||||
/** 选中文本变更回调 */
|
||||
onSelectionTextChange?: (text: string) => void;
|
||||
/** 当前项目 ID(用于跨页面插图匹配) */
|
||||
projectId?: string | null;
|
||||
/** 当前文稿 ID(用于跨页面插图匹配) */
|
||||
contentId?: string | null;
|
||||
/** 自动配图的主题关键词 */
|
||||
autoImageTopic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +90,10 @@ export interface DocumentToolbarProps {
|
||||
onCancel: () => void;
|
||||
/** 导出回调 */
|
||||
onExport: (format: ExportFormat) => void;
|
||||
/** 主题自动配图 */
|
||||
onAutoInsertImages?: () => void;
|
||||
/** 自动配图执行中 */
|
||||
autoInsertLoading?: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applySectionImageAssignments,
|
||||
appendImageToMarkdown,
|
||||
buildSectionSearchQuery,
|
||||
extractLevel2Sections,
|
||||
} from "./autoImageInsert";
|
||||
|
||||
describe("autoImageInsert", () => {
|
||||
it("应解析二级标题小节", () => {
|
||||
const markdown = `# 标题
|
||||
|
||||
## 第一节
|
||||
内容一
|
||||
|
||||
## 第二节
|
||||
内容二`;
|
||||
const sections = extractLevel2Sections(markdown);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].title).toBe("第一节");
|
||||
expect(sections[1].title).toBe("第二节");
|
||||
});
|
||||
|
||||
it("应按小节插入图片并附带来源", () => {
|
||||
const markdown = `# 城市观察
|
||||
|
||||
## 交通
|
||||
讨论交通现象
|
||||
|
||||
## 建筑
|
||||
讨论建筑空间`;
|
||||
|
||||
const next = applySectionImageAssignments(markdown, [
|
||||
{
|
||||
sectionTitle: "交通",
|
||||
image: {
|
||||
id: "img-1",
|
||||
previewUrl: "https://img.example/traffic-thumb.jpg",
|
||||
contentUrl: "https://img.example/traffic.jpg",
|
||||
pageUrl: "https://img.example/page-traffic",
|
||||
attributionName: "Pexels",
|
||||
title: "交通图",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(next).toContain("");
|
||||
expect(next).toContain("> 图片来源:[Pexels](https://img.example/page-traffic)");
|
||||
});
|
||||
|
||||
it("appendImageToMarkdown 应避免重复插入", () => {
|
||||
const base = "## 小节\n内容";
|
||||
const once = appendImageToMarkdown(base, {
|
||||
id: "img-1",
|
||||
previewUrl: "https://img.example/thumb.jpg",
|
||||
contentUrl: "https://img.example/full.jpg",
|
||||
title: "示例图",
|
||||
});
|
||||
const twice = appendImageToMarkdown(once, {
|
||||
id: "img-1",
|
||||
previewUrl: "https://img.example/thumb.jpg",
|
||||
contentUrl: "https://img.example/full.jpg",
|
||||
title: "示例图",
|
||||
});
|
||||
expect(once).toBe(twice);
|
||||
});
|
||||
|
||||
it("应生成包含主题和小节的检索词", () => {
|
||||
const query = buildSectionSearchQuery("城市夜景", "交通");
|
||||
expect(query).toContain("城市夜景");
|
||||
expect(query).toContain("交通");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { InsertableImage } from "@/lib/documentImageInsertBus";
|
||||
|
||||
export interface MarkdownSectionAnchor {
|
||||
title: string;
|
||||
headingLineIndex: number;
|
||||
contentStartLineIndex: number;
|
||||
nextHeadingLineIndex: number;
|
||||
}
|
||||
|
||||
export interface SectionImageAssignment {
|
||||
sectionTitle: string;
|
||||
image: InsertableImage;
|
||||
}
|
||||
|
||||
export interface ApplySectionImageAssignmentsOptions {
|
||||
includeAttribution?: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_MARKDOWN_REGEX = /!\[[^\]]*\]\([^)]+\)/;
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function isSectionHeading(line: string): boolean {
|
||||
return /^##\s+/.test(line.trim());
|
||||
}
|
||||
|
||||
function extractSectionTitle(line: string): string {
|
||||
return line.replace(/^##\s+/, "").trim();
|
||||
}
|
||||
|
||||
export function extractLevel2Sections(markdown: string): MarkdownSectionAnchor[] {
|
||||
const lines = markdown.split("\n");
|
||||
const headings = lines
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter((entry) => isSectionHeading(entry.line));
|
||||
|
||||
return headings.map((heading, currentIndex) => {
|
||||
const nextHeadingIndex =
|
||||
currentIndex < headings.length - 1
|
||||
? headings[currentIndex + 1].index
|
||||
: lines.length;
|
||||
return {
|
||||
title: extractSectionTitle(heading.line),
|
||||
headingLineIndex: heading.index,
|
||||
contentStartLineIndex: heading.index + 1,
|
||||
nextHeadingLineIndex: nextHeadingIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function hasImageInSection(lines: string[], section: MarkdownSectionAnchor): boolean {
|
||||
for (
|
||||
let lineIndex = section.contentStartLineIndex;
|
||||
lineIndex < section.nextHeadingLineIndex;
|
||||
lineIndex += 1
|
||||
) {
|
||||
if (IMAGE_MARKDOWN_REGEX.test(lines[lineIndex])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveInsertLineIndex(
|
||||
lines: string[],
|
||||
section: MarkdownSectionAnchor,
|
||||
): number {
|
||||
for (
|
||||
let lineIndex = section.contentStartLineIndex;
|
||||
lineIndex < section.nextHeadingLineIndex;
|
||||
lineIndex += 1
|
||||
) {
|
||||
if (lines[lineIndex]?.trim()) {
|
||||
return lineIndex;
|
||||
}
|
||||
}
|
||||
return section.contentStartLineIndex;
|
||||
}
|
||||
|
||||
function createImageMarkdown(
|
||||
image: InsertableImage,
|
||||
includeAttribution: boolean,
|
||||
): string[] {
|
||||
const altText = (image.title || "插图").trim() || "插图";
|
||||
const lines = [``];
|
||||
|
||||
if (includeAttribution) {
|
||||
const attributionName =
|
||||
image.attributionName || image.provider || "图片来源";
|
||||
if (image.pageUrl) {
|
||||
lines.push(`> 图片来源:[${attributionName}](${image.pageUrl})`);
|
||||
} else {
|
||||
lines.push(`> 图片来源:${attributionName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function findBestSection(
|
||||
sections: MarkdownSectionAnchor[],
|
||||
sectionTitle: string,
|
||||
): MarkdownSectionAnchor | null {
|
||||
if (!sectionTitle) {
|
||||
return sections[0] ?? null;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeTitle(sectionTitle);
|
||||
const exactMatch = sections.find(
|
||||
(section) => normalizeTitle(section.title) === normalizedTarget,
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const fuzzyMatch = sections.find((section) =>
|
||||
normalizeTitle(section.title).includes(normalizedTarget),
|
||||
);
|
||||
return fuzzyMatch ?? sections[0] ?? null;
|
||||
}
|
||||
|
||||
export function appendImageToMarkdown(
|
||||
markdown: string,
|
||||
image: InsertableImage,
|
||||
includeAttribution = true,
|
||||
): string {
|
||||
if (!image.contentUrl || markdown.includes(`(${image.contentUrl})`)) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const blockLines = createImageMarkdown(image, includeAttribution);
|
||||
const trimmed = markdown.trimEnd();
|
||||
if (!trimmed) {
|
||||
return `${blockLines.join("\n")}\n`;
|
||||
}
|
||||
return `${trimmed}\n\n${blockLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function applySectionImageAssignments(
|
||||
markdown: string,
|
||||
assignments: SectionImageAssignment[],
|
||||
options?: ApplySectionImageAssignmentsOptions,
|
||||
): string {
|
||||
if (!assignments.length) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const includeAttribution = options?.includeAttribution ?? true;
|
||||
const lines = markdown.split("\n");
|
||||
const sections = extractLevel2Sections(markdown);
|
||||
|
||||
if (sections.length === 0) {
|
||||
let nextContent = markdown;
|
||||
for (const assignment of assignments) {
|
||||
nextContent = appendImageToMarkdown(
|
||||
nextContent,
|
||||
assignment.image,
|
||||
includeAttribution,
|
||||
);
|
||||
}
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
const insertOperations: Array<{ lineIndex: number; lines: string[] }> = [];
|
||||
const usedSectionTitles = new Set<string>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (!assignment.image.contentUrl) continue;
|
||||
if (markdown.includes(`(${assignment.image.contentUrl})`)) continue;
|
||||
|
||||
const section = findBestSection(sections, assignment.sectionTitle);
|
||||
if (!section) continue;
|
||||
|
||||
const normalizedKey = normalizeTitle(section.title);
|
||||
if (usedSectionTitles.has(normalizedKey)) continue;
|
||||
if (hasImageInSection(lines, section)) continue;
|
||||
|
||||
const insertLineIndex = resolveInsertLineIndex(lines, section);
|
||||
const blockLines = createImageMarkdown(assignment.image, includeAttribution);
|
||||
insertOperations.push({
|
||||
lineIndex: insertLineIndex,
|
||||
lines: [...blockLines, ""],
|
||||
});
|
||||
usedSectionTitles.add(normalizedKey);
|
||||
}
|
||||
|
||||
if (!insertOperations.length) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
insertOperations
|
||||
.sort((a, b) => b.lineIndex - a.lineIndex)
|
||||
.forEach((operation) => {
|
||||
lines.splice(operation.lineIndex, 0, ...operation.lines);
|
||||
});
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
export function buildSectionSearchQuery(
|
||||
topic: string | undefined,
|
||||
sectionTitle: string,
|
||||
): string {
|
||||
const baseTopic = (topic || "").trim();
|
||||
const baseSection = sectionTitle.trim();
|
||||
if (!baseTopic && !baseSection) {
|
||||
return "";
|
||||
}
|
||||
if (!baseTopic) {
|
||||
return `${baseSection} 高清摄影`;
|
||||
}
|
||||
if (!baseSection) {
|
||||
return `${baseTopic} 高清摄影`;
|
||||
}
|
||||
return `${baseTopic} ${baseSection} 高清摄影`;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* @module components/content-creator/canvas/music/MusicCanvas
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useCallback, useState } from "react";
|
||||
import React, { memo, useMemo, useCallback, useState, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import type { MusicCanvasProps, MusicViewMode } from "./types";
|
||||
import { createSection } from "./types";
|
||||
import { MusicToolbar } from "./MusicToolbar";
|
||||
import {
|
||||
NumberedNotationRenderer,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
} from "./renderers";
|
||||
import { Copy, Check, Music } from "lucide-react";
|
||||
import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
/** 段落类型中文映射 */
|
||||
const SECTION_DISPLAY_NAMES: Record<string, string> = {
|
||||
@@ -87,7 +96,7 @@ const LyricsContainer = styled.div`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
const SectionBlock = styled.div<{ $isSelected: boolean; $flash?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
@@ -97,11 +106,21 @@ const SectionBlock = styled.div<{ $isSelected: boolean }>`
|
||||
${({ $isSelected }) =>
|
||||
$isSelected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
cursor: pointer;
|
||||
animation: ${({ $flash }) => ($flash ? "musicInsertFlash 1.6s ease" : "none")};
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.05);
|
||||
}
|
||||
|
||||
@keyframes musicInsertFlash {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--primary) / 0.55);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--primary) / 0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
@@ -228,9 +247,20 @@ const StatusItem = styled.span`
|
||||
* 音乐画布主组件
|
||||
*/
|
||||
export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
({ state, onStateChange, onBackHome, onClose, isStreaming = false }) => {
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
projectId,
|
||||
contentId,
|
||||
onBackHome,
|
||||
onClose,
|
||||
isStreaming = false,
|
||||
}) => {
|
||||
const [toastMessage, setToastMessage] = useState("");
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [highlightedSectionId, setHighlightedSectionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// 统计信息
|
||||
const stats = useMemo(() => {
|
||||
@@ -255,6 +285,116 @@ export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
setTimeout(() => setShowToast(false), 2000);
|
||||
}, []);
|
||||
|
||||
const matchesRequestTarget = useCallback(
|
||||
(request: CanvasImageInsertRequest): boolean =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "music",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
const processInsertRequest = useCallback(
|
||||
(request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = request.image.contentUrl?.trim();
|
||||
if (!imageUrl) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "music",
|
||||
reason: "invalid_image_url",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
let sections = [...state.sections];
|
||||
let targetSection =
|
||||
sections.find((section) => section.id === state.currentSectionId) ||
|
||||
sections[0] ||
|
||||
null;
|
||||
|
||||
if (!targetSection) {
|
||||
targetSection = createSection("verse", 1);
|
||||
sections = [targetSection];
|
||||
}
|
||||
|
||||
if (
|
||||
targetSection.lyricsLines.some(
|
||||
(line) => line.includes(`](${imageUrl})`) || line.includes(imageUrl),
|
||||
)
|
||||
) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "music",
|
||||
locationLabel: `${targetSection.name} 段落`,
|
||||
reason: "duplicate",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const alt = request.image.title?.trim() || "灵感配图";
|
||||
const imageLine = ``;
|
||||
sections = sections.map((section) =>
|
||||
section.id === targetSection?.id
|
||||
? {
|
||||
...section,
|
||||
lyricsLines: [...section.lyricsLines, imageLine],
|
||||
}
|
||||
: section,
|
||||
);
|
||||
|
||||
onStateChange({
|
||||
...state,
|
||||
sections,
|
||||
currentSectionId: targetSection.id,
|
||||
});
|
||||
setHighlightedSectionId(targetSection.id);
|
||||
showMessage(`🖼️ 已插入到音乐段落「${targetSection.name}」`);
|
||||
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: true,
|
||||
canvasType: "music",
|
||||
locationLabel: `${targetSection.name} 段落末尾`,
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
},
|
||||
[matchesRequestTarget, onStateChange, showMessage, state],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
getPendingCanvasImageInsertRequests().forEach((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedSectionId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setHighlightedSectionId((prev) =>
|
||||
prev === highlightedSectionId ? null : prev,
|
||||
);
|
||||
}, 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [highlightedSectionId]);
|
||||
|
||||
// 切换视图模式
|
||||
const handleViewModeChange = useCallback(
|
||||
(viewMode: MusicViewMode) => {
|
||||
@@ -336,6 +476,7 @@ export const MusicCanvas: React.FC<MusicCanvasProps> = memo(
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
$isSelected={section.id === state.currentSectionId}
|
||||
$flash={section.id === highlightedSectionId}
|
||||
onClick={() => handleSectionSelect(section.id)}
|
||||
>
|
||||
<SectionHeader>
|
||||
|
||||
@@ -451,6 +451,10 @@ export interface MusicCanvasProps {
|
||||
state: MusicCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: MusicCanvasState) => void;
|
||||
/** 当前项目 ID(用于跨页插图) */
|
||||
projectId?: string | null;
|
||||
/** 当前内容 ID(用于跨页插图) */
|
||||
contentId?: string | null;
|
||||
/** 返回首页回调 */
|
||||
onBackHome?: () => void;
|
||||
/** 关闭画布回调 */
|
||||
|
||||
@@ -17,9 +17,19 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { NotionEditor } from "@/components/content-creator/canvas/document/editor";
|
||||
import { toast } from "sonner";
|
||||
import type { NovelCanvasState, Chapter } from "./types";
|
||||
import { countWords } from "./types";
|
||||
import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
type InsertableImage,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -98,7 +108,7 @@ const StatItem = styled.div`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ChapterItem = styled.div<{ $active?: boolean }>`
|
||||
const ChapterItem = styled.div<{ $active?: boolean; $flash?: boolean }>`
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
@@ -110,12 +120,24 @@ const ChapterItem = styled.div<{ $active?: boolean }>`
|
||||
$active ? "hsl(var(--accent) / 0.55)" : "hsl(var(--background))"};
|
||||
box-shadow: ${({ $active }) =>
|
||||
$active ? "0 2px 8px hsl(var(--primary) / 0.12)" : "none"};
|
||||
animation: ${({ $flash }) => ($flash ? "novelInsertFlash 1.8s ease" : "none")};
|
||||
transition: all 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent) / 0.42);
|
||||
border-color: hsl(var(--primary) / 0.28);
|
||||
}
|
||||
|
||||
@keyframes novelInsertFlash {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--primary) / 0.55);
|
||||
border-color: hsl(var(--primary) / 0.7);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--primary) / 0);
|
||||
border-color: hsl(var(--primary) / 0.28);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ChapterTitle = styled.div`
|
||||
@@ -176,6 +198,8 @@ const EmptyEditorState = styled.div`
|
||||
interface NovelCanvasProps {
|
||||
state: NovelCanvasState;
|
||||
onStateChange: (state: NovelCanvasState) => void;
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
onBackHome?: () => void;
|
||||
onClose: () => void;
|
||||
useExternalToolbar?: boolean;
|
||||
@@ -226,10 +250,31 @@ function _sanitizeChapterContent(content: string): string {
|
||||
return content;
|
||||
}
|
||||
|
||||
function appendImageMarkdown(content: string, image: InsertableImage): string {
|
||||
const imageUrl = image.contentUrl?.trim();
|
||||
if (!imageUrl) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (content.includes(`](${imageUrl})`) || content.includes(imageUrl)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const alt = image.title?.trim() || "插图";
|
||||
const snippet = ``;
|
||||
const trimmed = content.trimEnd();
|
||||
if (!trimmed) {
|
||||
return snippet;
|
||||
}
|
||||
return `${trimmed}\n\n${snippet}\n`;
|
||||
}
|
||||
|
||||
export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
projectId,
|
||||
contentId,
|
||||
onBackHome,
|
||||
onClose,
|
||||
useExternalToolbar = false,
|
||||
@@ -240,6 +285,9 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
const [internalChapterListCollapsed, setInternalChapterListCollapsed] =
|
||||
useState(false);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const [highlightedChapterId, setHighlightedChapterId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const isChapterListCollapsed =
|
||||
chapterListCollapsed ?? internalChapterListCollapsed;
|
||||
const currentChapter = state.chapters.find(
|
||||
@@ -351,6 +399,112 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
onSelectionTextChange?.("");
|
||||
}, [state.currentChapterId, onSelectionTextChange]);
|
||||
|
||||
const matchesRequestTarget = useCallback(
|
||||
(request: CanvasImageInsertRequest): boolean =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "novel",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
const processInsertRequest = useCallback(
|
||||
(request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let chapters = [...state.chapters];
|
||||
let targetChapter =
|
||||
chapters.find((chapter) => chapter.id === state.currentChapterId) ||
|
||||
chapters[0] ||
|
||||
null;
|
||||
|
||||
if (!targetChapter) {
|
||||
targetChapter = {
|
||||
id: crypto.randomUUID(),
|
||||
number: 1,
|
||||
title: "第一章",
|
||||
content: "# 第一章\n\n",
|
||||
wordCount: 0,
|
||||
status: "draft",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
chapters = [targetChapter];
|
||||
}
|
||||
|
||||
const nextContent = appendImageMarkdown(targetChapter.content, request.image);
|
||||
if (nextContent === targetChapter.content) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "novel",
|
||||
locationLabel: `${targetChapter.title} 末尾`,
|
||||
reason: "duplicate",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
chapters = chapters.map((chapter) =>
|
||||
chapter.id === targetChapter?.id
|
||||
? {
|
||||
...chapter,
|
||||
content: nextContent,
|
||||
wordCount: countWords(nextContent),
|
||||
updatedAt: now,
|
||||
}
|
||||
: chapter,
|
||||
);
|
||||
|
||||
onStateChange({
|
||||
...state,
|
||||
chapters,
|
||||
currentChapterId: targetChapter.id,
|
||||
});
|
||||
setEditorKey((prev) => prev + 1);
|
||||
setHighlightedChapterId(targetChapter.id);
|
||||
toast.success(`已插入到小说《${targetChapter.title}》`);
|
||||
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: true,
|
||||
canvasType: "novel",
|
||||
locationLabel: `${targetChapter.title} 末尾`,
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
},
|
||||
[matchesRequestTarget, onStateChange, state],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
getPendingCanvasImageInsertRequests().forEach((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedChapterId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setHighlightedChapterId((prev) =>
|
||||
prev === highlightedChapterId ? null : prev,
|
||||
);
|
||||
}, 1800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [highlightedChapterId]);
|
||||
|
||||
const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0);
|
||||
const completedCount = state.chapters.filter(
|
||||
(c) => c.status === "completed",
|
||||
@@ -405,6 +559,7 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
<ChapterItem
|
||||
key={chapter.id}
|
||||
$active={chapter.id === state.currentChapterId}
|
||||
$flash={chapter.id === highlightedChapterId}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
>
|
||||
<ChapterTitle>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import React, { memo, useRef, useEffect, useState, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { toast } from "sonner";
|
||||
import { PosterToolbar } from "./PosterToolbar";
|
||||
import { ElementToolbar } from "./ElementToolbar";
|
||||
import { LayerPanel } from "./LayerPanel";
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
} from "./elements";
|
||||
import type { PosterCanvasProps, ShapeType } from "./types";
|
||||
import type { AlignDirection } from "./utils/alignmentGuides";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -102,7 +111,7 @@ const GridOverlay = styled.div<{
|
||||
* @returns 海报画布组件
|
||||
*/
|
||||
export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
({ state, onStateChange, onBackHome, onClose }) => {
|
||||
({ state, onStateChange, projectId, contentId, onBackHome, onClose }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -157,7 +166,7 @@ export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
|
||||
// 使用元素 Hooks
|
||||
const { addText } = useTextElement({ canvas });
|
||||
const { addImageFromFile } = useImageElement({ canvas });
|
||||
const { addImage, addImageFromFile } = useImageElement({ canvas });
|
||||
const { addShape } = useShapeElement({ canvas });
|
||||
const { setSolidBackground } = useBackgroundElement({ canvas });
|
||||
|
||||
@@ -347,6 +356,64 @@ export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
[alignSelectedElements],
|
||||
);
|
||||
|
||||
const matchesRequestTarget = useCallback(
|
||||
(request: CanvasImageInsertRequest): boolean =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "poster",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
const processInsertRequest = useCallback(
|
||||
async (request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
const imageUrl = request.image.contentUrl?.trim();
|
||||
if (!imageUrl) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "poster",
|
||||
reason: "invalid_image_url",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const insertedId = await addImage(imageUrl);
|
||||
const success = Boolean(insertedId);
|
||||
if (success) {
|
||||
toast.success("已插入到海报画布中央");
|
||||
}
|
||||
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success,
|
||||
canvasType: "poster",
|
||||
locationLabel: success ? "当前画布中心" : undefined,
|
||||
reason: success ? undefined : "poster_insert_failed",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
},
|
||||
[addImage, matchesRequestTarget],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
void processInsertRequest(request);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
getPendingCanvasImageInsertRequests().forEach((request) => {
|
||||
void processInsertRequest(request);
|
||||
});
|
||||
}, [processInsertRequest]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="海报" onBackHome={onBackHome} />
|
||||
|
||||
@@ -168,6 +168,10 @@ export interface PosterCanvasProps {
|
||||
state: PosterCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: PosterCanvasState) => void;
|
||||
/** 当前项目 ID(用于跨页插图) */
|
||||
projectId?: string | null;
|
||||
/** 当前内容 ID(用于跨页插图) */
|
||||
contentId?: string | null;
|
||||
/** 返回首页回调 */
|
||||
onBackHome?: () => void;
|
||||
/** 关闭画布回调 */
|
||||
|
||||
@@ -4,15 +4,25 @@
|
||||
* 用于短剧项目的剧本编辑
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from "react";
|
||||
import React, { memo, useCallback, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { X, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import type { ScriptCanvasState, Scene, Dialogue } from "./types";
|
||||
import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
type InsertableImage,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -69,16 +79,26 @@ const SceneListHeader = styled.div`
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const SceneItem = styled.div<{ $active?: boolean }>`
|
||||
const SceneItem = styled.div<{ $active?: boolean; $flash?: boolean }>`
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: ${({ $active }) =>
|
||||
$active ? "hsl(var(--accent))" : "transparent"};
|
||||
animation: ${({ $flash }) => ($flash ? "scriptInsertFlash 1.6s ease" : "none")};
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
@keyframes scriptInsertFlash {
|
||||
0% {
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.65);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 0 hsl(var(--primary) / 0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SceneNumber = styled.div`
|
||||
@@ -135,12 +155,29 @@ const _CharacterName = styled.div`
|
||||
interface ScriptCanvasProps {
|
||||
state: ScriptCanvasState;
|
||||
onStateChange: (state: ScriptCanvasState) => void;
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
onBackHome?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function appendImageMarkdown(text: string, image: InsertableImage): string {
|
||||
const imageUrl = image.contentUrl?.trim();
|
||||
if (!imageUrl) return text;
|
||||
if (text.includes(`](${imageUrl})`) || text.includes(imageUrl)) {
|
||||
return text;
|
||||
}
|
||||
const alt = image.title?.trim() || "场景参考图";
|
||||
const snippet = ``;
|
||||
const trimmed = text.trimEnd();
|
||||
return trimmed ? `${trimmed}\n\n${snippet}` : snippet;
|
||||
}
|
||||
|
||||
export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
|
||||
({ state, onStateChange, onBackHome, onClose }) => {
|
||||
({ state, onStateChange, projectId, contentId, onBackHome, onClose }) => {
|
||||
const [highlightedSceneId, setHighlightedSceneId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const currentScene = state.scenes.find(
|
||||
(s) => s.id === state.currentSceneId,
|
||||
);
|
||||
@@ -213,6 +250,105 @@ export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
|
||||
[currentScene, handleUpdateScene],
|
||||
);
|
||||
|
||||
const matchesRequestTarget = useCallback(
|
||||
(request: CanvasImageInsertRequest): boolean =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "script",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
const processInsertRequest = useCallback(
|
||||
(request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowScene =
|
||||
state.scenes.find((scene) => scene.id === state.currentSceneId) ||
|
||||
state.scenes[0] ||
|
||||
null;
|
||||
if (!nowScene) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "script",
|
||||
reason: "no_scene",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDescription = appendImageMarkdown(
|
||||
nowScene.description || "",
|
||||
request.image,
|
||||
);
|
||||
if (nextDescription === (nowScene.description || "")) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "script",
|
||||
locationLabel: `第${nowScene.number}场`,
|
||||
reason: "duplicate",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = state.scenes.map((scene) =>
|
||||
scene.id === nowScene.id
|
||||
? {
|
||||
...scene,
|
||||
description: nextDescription,
|
||||
}
|
||||
: scene,
|
||||
);
|
||||
onStateChange({
|
||||
...state,
|
||||
scenes: updatedScenes,
|
||||
currentSceneId: nowScene.id,
|
||||
});
|
||||
setHighlightedSceneId(nowScene.id);
|
||||
toast.success(`已插入到剧本第${nowScene.number}场`);
|
||||
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: true,
|
||||
canvasType: "script",
|
||||
locationLabel: `第${nowScene.number}场 场景描述`,
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
},
|
||||
[matchesRequestTarget, onStateChange, state],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
getPendingCanvasImageInsertRequests().forEach((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
}, [processInsertRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedSceneId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setHighlightedSceneId((prev) =>
|
||||
prev === highlightedSceneId ? null : prev,
|
||||
);
|
||||
}, 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [highlightedSceneId]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="剧本" onBackHome={onBackHome} />
|
||||
@@ -238,6 +374,7 @@ export const ScriptCanvas: React.FC<ScriptCanvasProps> = memo(
|
||||
<SceneItem
|
||||
key={scene.id}
|
||||
$active={scene.id === state.currentSceneId}
|
||||
$flash={scene.id === highlightedSceneId}
|
||||
onClick={() => handleSceneSelect(scene.id)}
|
||||
>
|
||||
<SceneNumber>第{scene.number}场</SceneNumber>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { memo, useEffect, useMemo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -12,6 +13,14 @@ import { VideoSidebar, type VideoProviderOption } from "./VideoSidebar";
|
||||
import { VideoWorkspace } from "./VideoWorkspace";
|
||||
import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider";
|
||||
import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertAck,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
matchesCanvasImageInsertTarget,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
const VIDEO_MODEL_PRESETS: Record<string, string[]> = {
|
||||
doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
|
||||
@@ -198,7 +207,14 @@ const MainAction = styled.button`
|
||||
`;
|
||||
|
||||
export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
|
||||
({ state, onStateChange, projectId, onClose: _onClose, onBackHome }) => {
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
projectId,
|
||||
contentId,
|
||||
onClose: _onClose,
|
||||
onBackHome,
|
||||
}) => {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [topicPanelCollapsed, setTopicPanelCollapsed] = useState(false);
|
||||
const [providers, setProviders] = useState<VideoProviderOption[]>([]);
|
||||
@@ -280,6 +296,58 @@ export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
|
||||
}
|
||||
}, [availableModels, onStateChange, providers, state]);
|
||||
|
||||
const matchesRequestTarget = useMemo(
|
||||
() => (request: CanvasImageInsertRequest) =>
|
||||
matchesCanvasImageInsertTarget(request, {
|
||||
projectId: projectId || null,
|
||||
contentId: contentId || null,
|
||||
canvasType: "video",
|
||||
}),
|
||||
[contentId, projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const processInsertRequest = (request: CanvasImageInsertRequest) => {
|
||||
if (!matchesRequestTarget(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = request.image.contentUrl?.trim();
|
||||
if (!imageUrl) {
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: false,
|
||||
canvasType: "video",
|
||||
reason: "invalid_image_url",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
onStateChange({
|
||||
...state,
|
||||
startImage: imageUrl,
|
||||
});
|
||||
toast.success("已设置为视频起始参考图");
|
||||
|
||||
emitCanvasImageInsertAck({
|
||||
requestId: request.requestId,
|
||||
success: true,
|
||||
canvasType: "video",
|
||||
locationLabel: "起始画面参考图",
|
||||
});
|
||||
ackCanvasImageInsertRequest(request.requestId);
|
||||
};
|
||||
|
||||
const unsubscribe = onCanvasImageInsertRequest((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
getPendingCanvasImageInsertRequests().forEach((request) => {
|
||||
processInsertRequest(request);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [matchesRequestTarget, onStateChange, state]);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<CanvasBreadcrumbHeader label="视频" onBackHome={onBackHome} />
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface VideoCanvasProps {
|
||||
state: VideoCanvasState;
|
||||
onStateChange: (state: VideoCanvasState) => void;
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
onClose?: () => void;
|
||||
onBackHome?: () => void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw, Home, Bug } from "lucide-react";
|
||||
import { reportFrontendError } from "@/lib/crashReporting";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
@@ -108,18 +109,22 @@ export class ErrorBoundary extends Component<
|
||||
* 上报错误到监控服务(预留接口)
|
||||
*/
|
||||
private reportError(error: Error, errorInfo: ErrorInfo): void {
|
||||
// TODO: 集成错误监控服务(如 Sentry)
|
||||
// 目前仅在控制台输出
|
||||
const componentName = this.props.componentName ?? "GeneralChatErrorBoundary";
|
||||
const errorReport = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
timestamp: new Date().toISOString(),
|
||||
componentName: this.props.componentName,
|
||||
componentName,
|
||||
};
|
||||
|
||||
console.info("[ErrorBoundary] 错误报告:", errorReport);
|
||||
void reportFrontendError(error, {
|
||||
component: componentName,
|
||||
workflow_step: "general_chat_render",
|
||||
component_stack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { History, LocateFixed, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Page, PageParams } from "@/types/page";
|
||||
import type { CanvasImageTargetType } from "@/lib/canvasImageInsertBus";
|
||||
import { onCanvasImageInsertAck } from "@/lib/canvasImageInsertBus";
|
||||
import {
|
||||
clearCanvasImageInsertHistory,
|
||||
getCanvasImageInsertHistory,
|
||||
type CanvasImageInsertHistoryEntry,
|
||||
} from "@/lib/canvasImageInsertHistory";
|
||||
|
||||
const FloatingRoot = styled.div`
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FloatingButton = styled.button`
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background) / 0.92);
|
||||
color: hsl(var(--foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 28px hsl(var(--background) / 0.32);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
`;
|
||||
|
||||
const Panel = styled.div`
|
||||
width: 320px;
|
||||
max-height: 360px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--background) / 0.95);
|
||||
box-shadow: 0 12px 34px hsl(var(--background) / 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const PanelHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.6);
|
||||
`;
|
||||
|
||||
const PanelTitle = styled.div`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const HeaderActionButton = styled.button`
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
`;
|
||||
|
||||
const List = styled.div`
|
||||
overflow: auto;
|
||||
padding: 8px 10px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Item = styled.div`
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: hsl(var(--card) / 0.5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const ItemMeta = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`;
|
||||
|
||||
const ItemTitle = styled.div`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ItemHint = styled.div`
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const LocateButton = styled.button`
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
`;
|
||||
|
||||
const CANVAS_LABEL_MAP: Record<CanvasImageTargetType, string> = {
|
||||
auto: "当前画布",
|
||||
document: "文档",
|
||||
novel: "小说",
|
||||
script: "剧本",
|
||||
music: "音乐",
|
||||
poster: "海报",
|
||||
video: "视频",
|
||||
};
|
||||
|
||||
interface RecentImageInsertFloatingProps {
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
}
|
||||
|
||||
function getHistoryPreview(): CanvasImageInsertHistoryEntry[] {
|
||||
return getCanvasImageInsertHistory().slice(0, 8);
|
||||
}
|
||||
|
||||
export function RecentImageInsertFloating({
|
||||
onNavigate,
|
||||
}: RecentImageInsertFloatingProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [history, setHistory] = useState<CanvasImageInsertHistoryEntry[]>(
|
||||
getHistoryPreview,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeAck = onCanvasImageInsertAck(() => {
|
||||
setHistory(getHistoryPreview());
|
||||
});
|
||||
|
||||
const handleStorage = () => {
|
||||
setHistory(getHistoryPreview());
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
|
||||
return () => {
|
||||
unsubscribeAck();
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasHistory = history.length > 0;
|
||||
const visibleCount = useMemo(() => history.length, [history.length]);
|
||||
|
||||
if (!hasHistory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLocate = (entry: CanvasImageInsertHistoryEntry) => {
|
||||
onNavigate?.("agent", {
|
||||
projectId: entry.projectId,
|
||||
contentId: entry.contentId ?? undefined,
|
||||
theme: entry.theme,
|
||||
lockTheme: false,
|
||||
});
|
||||
setOpen(false);
|
||||
toast.success("正在定位到插图位置");
|
||||
};
|
||||
|
||||
const handleClearHistory = () => {
|
||||
clearCanvasImageInsertHistory();
|
||||
setHistory([]);
|
||||
setOpen(false);
|
||||
toast.success("已清空最近插图记录");
|
||||
};
|
||||
|
||||
return (
|
||||
<FloatingRoot>
|
||||
{open && (
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<PanelTitle>
|
||||
<History size={14} />
|
||||
最近插图记录
|
||||
</PanelTitle>
|
||||
<HeaderActions>
|
||||
<HeaderActionButton type="button" onClick={handleClearHistory}>
|
||||
清空
|
||||
</HeaderActionButton>
|
||||
<HeaderActionButton type="button" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</HeaderActionButton>
|
||||
</HeaderActions>
|
||||
</PanelHeader>
|
||||
<List>
|
||||
{history.map((entry) => {
|
||||
const canvasLabel = CANVAS_LABEL_MAP[entry.canvasType] || "画布";
|
||||
const locationLabel = entry.locationLabel || "已插入";
|
||||
return (
|
||||
<Item key={entry.requestId}>
|
||||
<ItemMeta>
|
||||
<ItemTitle>
|
||||
{entry.imageTitle?.trim() || "图片"} · {canvasLabel}
|
||||
</ItemTitle>
|
||||
<ItemHint>{locationLabel}</ItemHint>
|
||||
</ItemMeta>
|
||||
<LocateButton type="button" onClick={() => handleLocate(entry)}>
|
||||
<LocateFixed size={12} style={{ marginRight: 4 }} />
|
||||
定位
|
||||
</LocateButton>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<FloatingButton type="button" onClick={() => setOpen((prev) => !prev)}>
|
||||
{open ? <X size={14} /> : <History size={14} />}
|
||||
最近插图 {visibleCount}
|
||||
</FloatingButton>
|
||||
</FloatingRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecentImageInsertFloating;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
flushEffects,
|
||||
renderIntoDom,
|
||||
setReactActEnvironment,
|
||||
waitForCondition,
|
||||
type MountedRoot,
|
||||
} from "../test-utils";
|
||||
import { useImageSearch } from "./useImageSearch";
|
||||
|
||||
const { mockInvoke } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
interface HookHarness {
|
||||
getValue: () => ReturnType<typeof useImageSearch>;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function mountHook(): HookHarness {
|
||||
let hookValue: ReturnType<typeof useImageSearch> | null = null;
|
||||
|
||||
function TestComponent() {
|
||||
hookValue = useImageSearch();
|
||||
return null;
|
||||
}
|
||||
|
||||
renderIntoDom(<TestComponent />, mountedRoots);
|
||||
|
||||
return {
|
||||
getValue: () => {
|
||||
if (!hookValue) {
|
||||
throw new Error("hook 尚未初始化");
|
||||
}
|
||||
return hookValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setReactActEnvironment();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useImageSearch", () => {
|
||||
it("应正确映射 Pixabay 结果", async () => {
|
||||
mockInvoke.mockImplementation((command) => {
|
||||
if (command === "search_pixabay_images") {
|
||||
return Promise.resolve({
|
||||
total: 100,
|
||||
total_hits: 2,
|
||||
hits: [
|
||||
{
|
||||
id: 1,
|
||||
preview_url: "https://pixabay.example/preview.jpg",
|
||||
large_image_url: "https://pixabay.example/large.jpg",
|
||||
image_width: 1200,
|
||||
image_height: 800,
|
||||
tags: "forest,tree",
|
||||
page_url: "https://pixabay.com/photos/forest",
|
||||
user: "pixabay-user",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
const harness = mountHook();
|
||||
await act(async () => {
|
||||
await harness.getValue().search("pixabay", "forest", true);
|
||||
});
|
||||
|
||||
const state = harness.getValue().sourceStates.pixabay;
|
||||
expect(state.total).toBe(2);
|
||||
expect(state.results).toHaveLength(1);
|
||||
expect(state.results[0]).toMatchObject({
|
||||
id: "1",
|
||||
previewUrl: "https://pixabay.example/preview.jpg",
|
||||
largeUrl: "https://pixabay.example/large.jpg",
|
||||
provider: "pixabay",
|
||||
});
|
||||
});
|
||||
|
||||
it("应正确映射联网(Pexels)结果", async () => {
|
||||
mockInvoke.mockImplementation((command) => {
|
||||
if (command === "search_web_images") {
|
||||
return Promise.resolve({
|
||||
total: 1,
|
||||
provider: "pexels",
|
||||
hits: [
|
||||
{
|
||||
id: "pex-1",
|
||||
thumbnail_url: "https://pexels.example/thumb.jpg",
|
||||
content_url: "https://pexels.example/original.jpg",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
name: "city night",
|
||||
host_page_url: "https://www.pexels.com/photo/city-night",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
const harness = mountHook();
|
||||
await act(async () => {
|
||||
await harness.getValue().search("web", "city", true);
|
||||
});
|
||||
|
||||
const state = harness.getValue().sourceStates.web;
|
||||
expect(state.total).toBe(1);
|
||||
expect(state.results).toHaveLength(1);
|
||||
expect(state.results[0]).toMatchObject({
|
||||
id: "pex-1",
|
||||
previewUrl: "https://pexels.example/thumb.jpg",
|
||||
largeUrl: "https://pexels.example/original.jpg",
|
||||
provider: "pexels",
|
||||
tags: "city night",
|
||||
});
|
||||
});
|
||||
|
||||
it("应兼容联网(Pexels)camelCase 字段", async () => {
|
||||
mockInvoke.mockImplementation((command) => {
|
||||
if (command === "search_web_images") {
|
||||
return Promise.resolve({
|
||||
total: 2,
|
||||
provider: "pexels",
|
||||
hits: [
|
||||
{
|
||||
id: "pex-camel-1",
|
||||
thumbnailUrl: "https://pexels.example/camel-thumb.jpg",
|
||||
contentUrl: "https://pexels.example/camel-original.jpg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
name: "camel city",
|
||||
hostPageUrl: "https://www.pexels.com/photo/camel-city",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
const harness = mountHook();
|
||||
await act(async () => {
|
||||
await harness.getValue().search("web", "city", true);
|
||||
});
|
||||
|
||||
const state = harness.getValue().sourceStates.web;
|
||||
expect(state.total).toBe(2);
|
||||
expect(state.results).toHaveLength(1);
|
||||
expect(state.results[0]).toMatchObject({
|
||||
id: "pex-camel-1",
|
||||
previewUrl: "https://pexels.example/camel-thumb.jpg",
|
||||
largeUrl: "https://pexels.example/camel-original.jpg",
|
||||
pageUrl: "https://www.pexels.com/photo/camel-city",
|
||||
provider: "pexels",
|
||||
});
|
||||
});
|
||||
|
||||
it("loadMore 应使用下一页请求并追加结果", async () => {
|
||||
mockInvoke.mockImplementation((command, payload) => {
|
||||
if (command === "search_pixabay_images") {
|
||||
const page = payload.req.page;
|
||||
return Promise.resolve({
|
||||
total: 40,
|
||||
total_hits: 40,
|
||||
hits: [
|
||||
{
|
||||
id: page,
|
||||
preview_url: `https://pixabay.example/${page}-preview.jpg`,
|
||||
large_image_url: `https://pixabay.example/${page}-large.jpg`,
|
||||
image_width: 1200,
|
||||
image_height: 800,
|
||||
tags: `tag-${page}`,
|
||||
page_url: `https://pixabay.com/photos/${page}`,
|
||||
user: "pixabay-user",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
const harness = mountHook();
|
||||
await act(async () => {
|
||||
await harness.getValue().search("pixabay", "mountain", true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
harness.getValue().loadMore("pixabay");
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => harness.getValue().sourceStates.pixabay.results.length === 2,
|
||||
50,
|
||||
"分页加载未完成",
|
||||
);
|
||||
|
||||
const state = harness.getValue().sourceStates.pixabay;
|
||||
expect(state.page).toBe(2);
|
||||
expect(state.results.map((item) => item.id)).toEqual(["1", "2"]);
|
||||
|
||||
const pageCalls = mockInvoke.mock.calls
|
||||
.filter(([command]) => command === "search_pixabay_images")
|
||||
.map(([, payload]) => payload.req.page);
|
||||
expect(pageCalls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("应维护来源独立缓存(互不污染)", async () => {
|
||||
mockInvoke.mockImplementation((command) => {
|
||||
if (command === "search_pixabay_images") {
|
||||
return Promise.resolve({
|
||||
total: 1,
|
||||
total_hits: 1,
|
||||
hits: [
|
||||
{
|
||||
id: 100,
|
||||
preview_url: "https://pixabay.example/p.jpg",
|
||||
large_image_url: "https://pixabay.example/l.jpg",
|
||||
image_width: 1000,
|
||||
image_height: 700,
|
||||
tags: "pixabay-only",
|
||||
page_url: "https://pixabay.com/photos/pixabay-only",
|
||||
user: "pix-user",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (command === "search_web_images") {
|
||||
return Promise.resolve({
|
||||
total: 1,
|
||||
provider: "pexels",
|
||||
hits: [
|
||||
{
|
||||
id: "w-1",
|
||||
thumbnail_url: "https://pexels.example/w-thumb.jpg",
|
||||
content_url: "https://pexels.example/w.jpg",
|
||||
width: 700,
|
||||
height: 1000,
|
||||
name: "web-only",
|
||||
host_page_url: "https://www.pexels.com/photo/web-only",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
|
||||
const harness = mountHook();
|
||||
await act(async () => {
|
||||
await harness.getValue().search("pixabay", "pixabay-query", true);
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.getValue().search("web", "web-query", true);
|
||||
});
|
||||
|
||||
const pixabayState = harness.getValue().sourceStates.pixabay;
|
||||
const webState = harness.getValue().sourceStates.web;
|
||||
|
||||
expect(pixabayState.results).toHaveLength(1);
|
||||
expect(pixabayState.results[0].provider).toBe("pixabay");
|
||||
expect(pixabayState.results[0].id).toBe("100");
|
||||
|
||||
expect(webState.results).toHaveLength(1);
|
||||
expect(webState.results[0].provider).toBe("pexels");
|
||||
expect(webState.results[0].id).toBe("w-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @file 图片搜索 Hook
|
||||
* @description 调用后端 Pixabay / 联网图片搜索命令,支持分源缓存
|
||||
* @module components/image-gen/hooks/useImageSearch
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type SearchSource = "web" | "pixabay";
|
||||
export type AspectRatioFilter = "all" | "landscape" | "portrait" | "square";
|
||||
|
||||
export interface SearchImageResult {
|
||||
id: string;
|
||||
previewUrl: string;
|
||||
largeUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
tags: string;
|
||||
pageUrl: string;
|
||||
user: string;
|
||||
provider: "pixabay" | "pexels";
|
||||
}
|
||||
|
||||
interface PixabaySearchRequest {
|
||||
query: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
orientation?: string;
|
||||
}
|
||||
|
||||
interface PixabaySearchResponse {
|
||||
total: number;
|
||||
total_hits?: number;
|
||||
totalHits?: number;
|
||||
hits: Array<{
|
||||
id: number;
|
||||
preview_url?: string;
|
||||
previewUrl?: string;
|
||||
large_image_url?: string;
|
||||
largeImageUrl?: string;
|
||||
image_width?: number;
|
||||
imageWidth?: number;
|
||||
image_height?: number;
|
||||
imageHeight?: number;
|
||||
tags: string;
|
||||
page_url?: string;
|
||||
pageUrl?: string;
|
||||
user: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WebImageSearchRequest {
|
||||
query: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
aspect?: AspectRatioFilter;
|
||||
}
|
||||
|
||||
interface WebImageSearchResponse {
|
||||
total: number;
|
||||
totalResults?: number;
|
||||
photos?: Array<{
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
alt?: string;
|
||||
src: {
|
||||
medium?: string;
|
||||
small?: string;
|
||||
tiny?: string;
|
||||
large?: string;
|
||||
large2x?: string;
|
||||
original?: string;
|
||||
landscape?: string;
|
||||
portrait?: string;
|
||||
};
|
||||
}>;
|
||||
provider: string;
|
||||
hits: Array<{
|
||||
id: string;
|
||||
thumbnail_url?: string;
|
||||
thumbnailUrl?: string;
|
||||
content_url?: string;
|
||||
contentUrl?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
host_page_url?: string;
|
||||
hostPageUrl?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SourceSearchState {
|
||||
results: SearchImageResult[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
error: string | null;
|
||||
lastQuery: string;
|
||||
}
|
||||
|
||||
type SourceSearchStateMap = Record<SearchSource, SourceSearchState>;
|
||||
|
||||
const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
function pickFirstString(...values: Array<string | undefined>): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickFirstNumber(...values: Array<number | undefined>): number {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function createInitialSourceState(): SourceSearchState {
|
||||
return {
|
||||
results: [],
|
||||
loading: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
error: null,
|
||||
lastQuery: "",
|
||||
};
|
||||
}
|
||||
|
||||
function mapPixabayOrientation(
|
||||
aspectRatio: AspectRatioFilter,
|
||||
): string | undefined {
|
||||
if (aspectRatio === "landscape") return "horizontal";
|
||||
if (aspectRatio === "portrait") return "vertical";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapWebAspect(
|
||||
aspectRatio: AspectRatioFilter,
|
||||
): AspectRatioFilter | undefined {
|
||||
return aspectRatio === "all" ? undefined : aspectRatio;
|
||||
}
|
||||
|
||||
function filterSquareIfNeeded(
|
||||
results: SearchImageResult[],
|
||||
aspectRatio: AspectRatioFilter,
|
||||
): SearchImageResult[] {
|
||||
if (aspectRatio !== "square") {
|
||||
return results;
|
||||
}
|
||||
|
||||
return results.filter((img) => {
|
||||
const ratio = img.width / img.height;
|
||||
return ratio > 0.9 && ratio < 1.1;
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatioFilter>("all");
|
||||
const [sourceStates, setSourceStates] = useState<SourceSearchStateMap>({
|
||||
web: createInitialSourceState(),
|
||||
pixabay: createInitialSourceState(),
|
||||
});
|
||||
|
||||
const search = useCallback(
|
||||
async (
|
||||
source: SearchSource,
|
||||
newQuery?: string,
|
||||
resetPage = true,
|
||||
targetPage?: number,
|
||||
) => {
|
||||
const searchQuery = (newQuery ?? query).trim();
|
||||
if (!searchQuery) {
|
||||
setSourceStates((prev) => ({
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
error: null,
|
||||
lastQuery: "",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const page = targetPage ?? (resetPage ? 1 : sourceStates[source].page);
|
||||
|
||||
setSourceStates((prev) => ({
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
loading: true,
|
||||
error: null,
|
||||
page,
|
||||
lastQuery: searchQuery,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
if (source === "pixabay") {
|
||||
const req: PixabaySearchRequest = {
|
||||
query: searchQuery,
|
||||
page,
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
orientation: mapPixabayOrientation(aspectRatio),
|
||||
};
|
||||
|
||||
const response = await invoke<PixabaySearchResponse>(
|
||||
"search_pixabay_images",
|
||||
{ req },
|
||||
);
|
||||
|
||||
const mapped = response.hits.map((hit) => ({
|
||||
id: String(hit.id),
|
||||
previewUrl: pickFirstString(hit.preview_url, hit.previewUrl),
|
||||
largeUrl: pickFirstString(hit.large_image_url, hit.largeImageUrl),
|
||||
width: pickFirstNumber(hit.image_width, hit.imageWidth),
|
||||
height: pickFirstNumber(hit.image_height, hit.imageHeight),
|
||||
tags: hit.tags,
|
||||
pageUrl: pickFirstString(hit.page_url, hit.pageUrl),
|
||||
user: hit.user,
|
||||
provider: "pixabay" as const,
|
||||
}));
|
||||
|
||||
const filtered = filterSquareIfNeeded(mapped, aspectRatio);
|
||||
|
||||
setSourceStates((prev) => {
|
||||
const nextResults = resetPage
|
||||
? filtered
|
||||
: [...prev[source].results, ...filtered];
|
||||
return {
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
results: nextResults,
|
||||
total: response.total_hits ?? response.totalHits ?? response.total,
|
||||
page,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastQuery: searchQuery,
|
||||
},
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const req: WebImageSearchRequest = {
|
||||
query: searchQuery,
|
||||
page,
|
||||
perPage: DEFAULT_PER_PAGE,
|
||||
aspect: mapWebAspect(aspectRatio),
|
||||
};
|
||||
const response = await invoke<WebImageSearchResponse>(
|
||||
"search_web_images",
|
||||
{
|
||||
req,
|
||||
},
|
||||
);
|
||||
|
||||
const normalizedHits =
|
||||
response.hits?.length > 0
|
||||
? response.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
previewUrl: pickFirstString(hit.thumbnail_url, hit.thumbnailUrl),
|
||||
largeUrl: pickFirstString(hit.content_url, hit.contentUrl),
|
||||
width: pickFirstNumber(hit.width),
|
||||
height: pickFirstNumber(hit.height),
|
||||
tags: hit.name,
|
||||
pageUrl: pickFirstString(hit.host_page_url, hit.hostPageUrl),
|
||||
user: response.provider || "pexels",
|
||||
provider: "pexels" as const,
|
||||
}))
|
||||
: (response.photos || []).map((photo) => {
|
||||
const contentUrl = pickFirstString(
|
||||
photo.src.large2x,
|
||||
photo.src.large,
|
||||
photo.src.original,
|
||||
photo.src.landscape,
|
||||
photo.src.portrait,
|
||||
photo.src.medium,
|
||||
photo.src.small,
|
||||
photo.src.tiny,
|
||||
);
|
||||
const previewUrl = pickFirstString(
|
||||
photo.src.medium,
|
||||
photo.src.small,
|
||||
photo.src.tiny,
|
||||
photo.src.landscape,
|
||||
photo.src.portrait,
|
||||
contentUrl,
|
||||
);
|
||||
return {
|
||||
id: String(photo.id),
|
||||
previewUrl,
|
||||
largeUrl: contentUrl,
|
||||
width: pickFirstNumber(photo.width),
|
||||
height: pickFirstNumber(photo.height),
|
||||
tags: photo.alt || "Pexels Image",
|
||||
pageUrl: photo.url,
|
||||
user: response.provider || "pexels",
|
||||
provider: "pexels" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const mapped = normalizedHits.filter(
|
||||
(hit) => hit.previewUrl && hit.largeUrl && hit.width > 0 && hit.height > 0,
|
||||
);
|
||||
const filtered = filterSquareIfNeeded(mapped, aspectRatio);
|
||||
setSourceStates((prev) => {
|
||||
const nextResults = resetPage
|
||||
? filtered
|
||||
: [...prev[source].results, ...filtered];
|
||||
return {
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
results: nextResults,
|
||||
total: response.total || response.totalResults || filtered.length,
|
||||
page,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastQuery: searchQuery,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${source} 搜索失败:`, error);
|
||||
setSourceStates((prev) => ({
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
loading: false,
|
||||
error: message,
|
||||
...(resetPage
|
||||
? {
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[aspectRatio, query, sourceStates],
|
||||
);
|
||||
|
||||
const loadMore = useCallback(
|
||||
(source: SearchSource) => {
|
||||
const current = sourceStates[source];
|
||||
if (current.loading || current.results.length >= current.total) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPage = current.page + 1;
|
||||
const nextQuery = current.lastQuery || query.trim();
|
||||
if (!nextQuery) {
|
||||
return;
|
||||
}
|
||||
void search(source, nextQuery, false, nextPage);
|
||||
},
|
||||
[query, search, sourceStates],
|
||||
);
|
||||
|
||||
const clear = useCallback((source?: SearchSource) => {
|
||||
if (source) {
|
||||
setSourceStates((prev) => ({
|
||||
...prev,
|
||||
[source]: createInitialSourceState(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setSourceStates({
|
||||
web: createInitialSourceState(),
|
||||
pixabay: createInitialSourceState(),
|
||||
});
|
||||
setQuery("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
aspectRatio,
|
||||
setAspectRatio,
|
||||
sourceStates,
|
||||
search,
|
||||
loadMore,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
flushEffects,
|
||||
renderIntoDom,
|
||||
setReactActEnvironment,
|
||||
waitForCondition,
|
||||
type MountedRoot,
|
||||
} from "../test-utils";
|
||||
|
||||
const { mockInvoke, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockEmitCanvasImageInsertRequest,
|
||||
mockOnCanvasImageInsertAck,
|
||||
mockGetActiveContentTarget,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEmitCanvasImageInsertRequest: vi.fn(),
|
||||
mockOnCanvasImageInsertAck: vi.fn(),
|
||||
mockGetActiveContentTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/scroll-area", () => ({
|
||||
ScrollArea: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="scroll-area">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/canvasImageInsertBus", () => ({
|
||||
emitCanvasImageInsertRequest: mockEmitCanvasImageInsertRequest,
|
||||
onCanvasImageInsertAck: mockOnCanvasImageInsertAck,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/activeContentTarget", () => ({
|
||||
getActiveContentTarget: mockGetActiveContentTarget,
|
||||
}));
|
||||
|
||||
import { ImageSearchTab } from "./ImageSearchTab";
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderTab(projectId = "project-1"): HTMLDivElement {
|
||||
const mounted = renderIntoDom(
|
||||
<ImageSearchTab projectId={projectId} />,
|
||||
mountedRoots,
|
||||
);
|
||||
return mounted.container;
|
||||
}
|
||||
|
||||
function findTextarea(container: HTMLElement): HTMLTextAreaElement {
|
||||
const node = container.querySelector("textarea");
|
||||
if (!node) {
|
||||
throw new Error("未找到搜索输入框");
|
||||
}
|
||||
return node as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLTextAreaElement, value: string) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
if (!nativeSetter) {
|
||||
throw new Error("未找到 textarea value setter");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
nativeSetter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setReactActEnvironment();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("open", vi.fn());
|
||||
mockGetActiveContentTarget.mockReturnValue({
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
canvasType: "document",
|
||||
});
|
||||
mockOnCanvasImageInsertAck.mockReturnValue(() => undefined);
|
||||
mockEmitCanvasImageInsertRequest.mockReturnValue({
|
||||
requestId: "insert-1",
|
||||
});
|
||||
|
||||
mockInvoke.mockImplementation((command, payload) => {
|
||||
if (command === "search_pixabay_images") {
|
||||
const page = payload.req.page;
|
||||
return Promise.resolve({
|
||||
total: 40,
|
||||
total_hits: 40,
|
||||
hits: [
|
||||
{
|
||||
id: page,
|
||||
preview_url: `https://pixabay.example/${page}-preview.jpg`,
|
||||
large_image_url: `https://pixabay.example/${page}-large.jpg`,
|
||||
image_width: 1200,
|
||||
image_height: 800,
|
||||
tags: `pixabay-${page}`,
|
||||
page_url: `https://pixabay.com/photos/${page}`,
|
||||
user: "pixabay-user",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (command === "search_web_images") {
|
||||
const page = payload.req.page;
|
||||
return Promise.resolve({
|
||||
total: 40,
|
||||
provider: "pexels",
|
||||
hits: [
|
||||
{
|
||||
id: `w-${page}`,
|
||||
thumbnail_url: `https://pexels.example/${page}-thumb.jpg`,
|
||||
content_url: `https://pexels.example/${page}-image.jpg`,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
name: `pexels-${page}`,
|
||||
host_page_url: `https://www.pexels.com/photo/${page}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (command === "import_material_from_url") {
|
||||
return Promise.resolve({ id: "mock-material-id" });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`unexpected command: ${command}`));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ImageSearchTab", () => {
|
||||
it("应支持切换来源并展示对应 attribution", async () => {
|
||||
const container = renderTab();
|
||||
await setInputValue(findTextarea(container), "city");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "搜索图片").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => container.textContent?.includes("图片来源: Pexels") ?? false,
|
||||
50,
|
||||
"未展示 Pexels 来源",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "Pixabay图库").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "搜索图片").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => container.textContent?.includes("图片来源: Pixabay") ?? false,
|
||||
50,
|
||||
"未展示 Pixabay 来源",
|
||||
);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith(
|
||||
"search_web_images",
|
||||
expect.objectContaining({
|
||||
req: expect.objectContaining({
|
||||
query: "city",
|
||||
page: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("在联网来源保存图片时应使用 pexels 标签", async () => {
|
||||
const container = renderTab();
|
||||
await setInputValue(findTextarea(container), "city");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "联网搜索").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "搜索图片").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => container.textContent?.includes("保存") ?? false,
|
||||
50,
|
||||
"搜索结果未渲染保存按钮",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "保存").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith(
|
||||
"import_material_from_url",
|
||||
expect.objectContaining({
|
||||
req: expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
type: "image",
|
||||
tags: ["pexels"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("当前来源加载更多应请求下一页", async () => {
|
||||
const container = renderTab();
|
||||
await setInputValue(findTextarea(container), "beach");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "联网搜索").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "搜索图片").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => container.textContent?.includes("加载更多") ?? false,
|
||||
50,
|
||||
"未出现加载更多按钮",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "加载更多").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const webSearchPages = mockInvoke.mock.calls
|
||||
.filter(([command]) => command === "search_web_images")
|
||||
.map(([, payload]) => payload.req.page);
|
||||
expect(webSearchPages).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("应支持将搜索图片插入当前画布", async () => {
|
||||
const container = renderTab();
|
||||
await setInputValue(findTextarea(container), "city");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "搜索图片").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
() => container.textContent?.includes("插入当前画布") ?? false,
|
||||
50,
|
||||
"搜索结果未渲染插入按钮",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "插入当前画布").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
canvasType: "document",
|
||||
source: "pexels",
|
||||
image: expect.objectContaining({
|
||||
contentUrl: "https://pexels.example/1-image.jpg",
|
||||
attributionName: "Pexels",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @file 本地图片 Tab
|
||||
* @description 从本地文件系统选择图片并保存到图片库
|
||||
* @module components/image-gen/tabs/LocalImageTab
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ImagePlus, Loader2, Upload, Trash2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
export interface LocalImageTabProps {
|
||||
/** 目标项目 ID */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
// ==================== Animations ====================
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
`;
|
||||
|
||||
const float = keyframes`
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
`;
|
||||
|
||||
// ==================== Styled Components ====================
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--background));
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const DropZone = styled.div<{ $dragging?: boolean }>`
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
min-height: 280px;
|
||||
border: 2px dashed
|
||||
${({ $dragging }) =>
|
||||
$dragging ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
border-radius: 20px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
background: ${({ $dragging }) =>
|
||||
$dragging
|
||||
? "hsl(var(--primary) / 0.06)"
|
||||
: "hsl(var(--card) / 0.3)"};
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--card) / 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px hsl(var(--background) / 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
const IconContainer = styled.div`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.12),
|
||||
hsl(var(--primary) / 0.06)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--primary) / 0.7);
|
||||
animation: ${float} 3s ease-in-out infinite;
|
||||
`;
|
||||
|
||||
const DropTitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
`;
|
||||
|
||||
const DropHint = styled.p`
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const PreviewContainer = styled.div`
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
animation: ${fadeIn} 0.35s ease;
|
||||
`;
|
||||
|
||||
const PreviewCard = styled.div`
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow: 0 8px 32px hsl(var(--background) / 0.4);
|
||||
max-height: 440px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button<{ $primary?: boolean; $danger?: boolean }>`
|
||||
height: 42px;
|
||||
padding: 0 24px;
|
||||
border: ${({ $primary }) =>
|
||||
$primary ? "none" : "1px solid hsl(var(--border))"};
|
||||
border-radius: 12px;
|
||||
background: ${({ $primary, $danger }) =>
|
||||
$primary
|
||||
? "linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.8))"
|
||||
: $danger
|
||||
? "transparent"
|
||||
: "hsl(var(--card) / 0.6)"};
|
||||
color: ${({ $primary, $danger }) =>
|
||||
$primary
|
||||
? "hsl(var(--primary-foreground))"
|
||||
: $danger
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(var(--foreground))"};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: ${({ $primary }) =>
|
||||
$primary
|
||||
? "0 6px 20px hsl(var(--primary) / 0.3)"
|
||||
: "0 4px 12px hsl(var(--background) / 0.3)"};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoProjectState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
`;
|
||||
|
||||
const NoProjectIcon = styled.div`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground) / 0.5);
|
||||
`;
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
export function LocalImageTab({ projectId }: LocalImageTabProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await open({
|
||||
filters: [
|
||||
{
|
||||
name: "图片",
|
||||
extensions: ["jpg", "jpeg", "png", "webp", "gif", "bmp"],
|
||||
},
|
||||
],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (filePath && typeof filePath === "string") {
|
||||
setSelectedPath(filePath);
|
||||
setPreviewUrl(`asset://localhost/${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("选择文件失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToGallery = async () => {
|
||||
if (!projectId) {
|
||||
toast.error("请先选择项目");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPath) {
|
||||
toast.error("请先选择图片");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await invoke("upload_material", {
|
||||
req: {
|
||||
project_id: projectId,
|
||||
name: selectedPath.split("/").pop() || "本地图片",
|
||||
type: "image",
|
||||
file_path: selectedPath,
|
||||
tags: ["local"],
|
||||
},
|
||||
});
|
||||
toast.success("已保存到图片库");
|
||||
|
||||
// 清空选择
|
||||
setPreviewUrl(null);
|
||||
setSelectedPath(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(`保存失败: ${message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPreviewUrl(null);
|
||||
setSelectedPath(null);
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<Container>
|
||||
<NoProjectState>
|
||||
<NoProjectIcon>
|
||||
<Upload size={28} />
|
||||
</NoProjectIcon>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: "hsl(var(--foreground) / 0.7)" }}>
|
||||
请先选择项目
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
在右上角选择一个项目后即可上传本地图片
|
||||
</div>
|
||||
</NoProjectState>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ScrollArea className="h-full">
|
||||
<Content>
|
||||
{previewUrl ? (
|
||||
<PreviewContainer>
|
||||
<PreviewCard>
|
||||
<img src={previewUrl} alt="预览" />
|
||||
</PreviewCard>
|
||||
<Actions>
|
||||
<ActionButton onClick={handleClear} disabled={saving}>
|
||||
<Trash2 size={16} />
|
||||
重新选择
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
$primary
|
||||
onClick={handleSaveToGallery}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={16} />
|
||||
保存到图片库
|
||||
</>
|
||||
)}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</PreviewContainer>
|
||||
) : (
|
||||
<DropZone onClick={handleSelectFile}>
|
||||
<IconContainer>
|
||||
<ImagePlus size={36} />
|
||||
</IconContainer>
|
||||
<DropTitle>选择本地图片</DropTitle>
|
||||
<DropHint>
|
||||
支持 JPG、PNG、WebP、GIF、BMP 格式
|
||||
<br />
|
||||
点击此区域选择文件
|
||||
</DropHint>
|
||||
</DropZone>
|
||||
)}
|
||||
</Content>
|
||||
</ScrollArea>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocalImageTab;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
flushEffects,
|
||||
renderIntoDom,
|
||||
setReactActEnvironment,
|
||||
type MountedRoot,
|
||||
} from "../test-utils";
|
||||
|
||||
const { mockConvertFileSrc } = vi.hoisted(() => ({
|
||||
mockConvertFileSrc: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockEmitCanvasImageInsertRequest,
|
||||
mockOnCanvasImageInsertAck,
|
||||
mockGetActiveContentTarget,
|
||||
} =
|
||||
vi.hoisted(() => ({
|
||||
mockEmitCanvasImageInsertRequest: vi.fn(),
|
||||
mockOnCanvasImageInsertAck: vi.fn(),
|
||||
mockGetActiveContentTarget: vi.fn(),
|
||||
}));
|
||||
const { mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
convertFileSrc: mockConvertFileSrc,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/canvasImageInsertBus", () => ({
|
||||
emitCanvasImageInsertRequest: mockEmitCanvasImageInsertRequest,
|
||||
onCanvasImageInsertAck: mockOnCanvasImageInsertAck,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/activeContentTarget", () => ({
|
||||
getActiveContentTarget: mockGetActiveContentTarget,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/content-creator/material/ImageGallery", () => ({
|
||||
ImageGallery: ({
|
||||
onSelect,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
onSelect?: (materials: unknown[]) => void;
|
||||
onDoubleClick?: (material: unknown) => void;
|
||||
}) => {
|
||||
const material = {
|
||||
id: "material-1",
|
||||
name: "城市夜景",
|
||||
type: "image",
|
||||
projectId: "project-1",
|
||||
filePath: "/tmp/city.jpg",
|
||||
tags: [],
|
||||
createdAt: Date.now(),
|
||||
metadata: {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="mock-image-gallery">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect?.([material]);
|
||||
}}
|
||||
>
|
||||
选择素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDoubleClick?.(material);
|
||||
}}
|
||||
>
|
||||
双击插入
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { MyGalleryTab } from "./MyGalleryTab";
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderTab(projectId: string | null = "project-1"): HTMLDivElement {
|
||||
const mounted = renderIntoDom(<MyGalleryTab projectId={projectId} />, mountedRoots);
|
||||
return mounted.container;
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setReactActEnvironment();
|
||||
vi.clearAllMocks();
|
||||
mockConvertFileSrc.mockReturnValue("asset://city.jpg");
|
||||
mockOnCanvasImageInsertAck.mockReturnValue(() => undefined);
|
||||
mockGetActiveContentTarget.mockReturnValue({
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
canvasType: "document",
|
||||
});
|
||||
mockEmitCanvasImageInsertRequest.mockReturnValue({
|
||||
requestId: "insert-1",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
});
|
||||
|
||||
describe("MyGalleryTab", () => {
|
||||
it("未选择项目时应提示先选择项目", () => {
|
||||
const container = renderTab(null);
|
||||
expect(container.textContent).toContain("请先选择项目");
|
||||
});
|
||||
|
||||
it("应支持选中后插入文稿", async () => {
|
||||
const container = renderTab("project-1");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "选择素材").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "插入选中图片到当前画布").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-1",
|
||||
contentId: "content-1",
|
||||
canvasType: "document",
|
||||
source: "gallery",
|
||||
image: expect.objectContaining({
|
||||
contentUrl: "asset://city.jpg",
|
||||
title: "城市夜景",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("双击素材应直接插入文稿", async () => {
|
||||
const container = renderTab("project-1");
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "双击插入").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* @file 我的图片库 Tab
|
||||
* @description 显示用户已保存的图片素材库
|
||||
* @module components/image-gen/tabs/MyGalleryTab
|
||||
*/
|
||||
|
||||
import { ImageGallery } from "@/components/content-creator/material/ImageGallery";
|
||||
import type { PosterMaterial } from "@/types/poster-material";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import { Images } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import { getActiveContentTarget } from "@/lib/activeContentTarget";
|
||||
import {
|
||||
emitCanvasImageInsertRequest,
|
||||
onCanvasImageInsertAck,
|
||||
type CanvasImageInsertAck,
|
||||
type CanvasImageTargetType,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
import {
|
||||
addCanvasImageInsertHistory,
|
||||
getCanvasImageInsertHistory,
|
||||
type CanvasImageInsertHistoryEntry,
|
||||
} from "@/lib/canvasImageInsertHistory";
|
||||
import type { Page, PageParams } from "@/types/page";
|
||||
|
||||
export interface MyGalleryTabProps {
|
||||
/** 项目 ID */
|
||||
projectId?: string | null;
|
||||
/** 页面跳转 */
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px 20px;
|
||||
`;
|
||||
|
||||
const ActionBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--card) / 0.5);
|
||||
`;
|
||||
|
||||
const ActionHint = styled.div`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
const InsertButton = styled.button`
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--accent) / 0.5);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const RelocateButton = styled(InsertButton)`
|
||||
font-size: 11px;
|
||||
padding: 5px 10px;
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
`;
|
||||
|
||||
const EmptyIcon = styled.div`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground) / 0.5);
|
||||
`;
|
||||
|
||||
const EmptyTitle = styled.p`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground) / 0.7);
|
||||
`;
|
||||
|
||||
const EmptyHint = styled.p`
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
function normalizeCanvasType(
|
||||
value: string | null | undefined,
|
||||
): CanvasImageTargetType {
|
||||
if (
|
||||
value === "document" ||
|
||||
value === "novel" ||
|
||||
value === "script" ||
|
||||
value === "music" ||
|
||||
value === "poster" ||
|
||||
value === "video"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "document";
|
||||
}
|
||||
|
||||
function mapCanvasTypeToTheme(canvasType: CanvasImageTargetType): string {
|
||||
switch (canvasType) {
|
||||
case "poster":
|
||||
return "poster";
|
||||
case "music":
|
||||
return "music";
|
||||
case "novel":
|
||||
return "novel";
|
||||
case "video":
|
||||
return "video";
|
||||
case "script":
|
||||
return "social-media";
|
||||
case "document":
|
||||
case "auto":
|
||||
default:
|
||||
return "document";
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleInsertHistory(
|
||||
projectId?: string | null,
|
||||
): CanvasImageInsertHistoryEntry[] {
|
||||
const history = getCanvasImageInsertHistory();
|
||||
const filtered = projectId
|
||||
? history.filter((entry) => entry.projectId === projectId)
|
||||
: history;
|
||||
return filtered.slice(0, 3);
|
||||
}
|
||||
|
||||
export function MyGalleryTab({ projectId, onNavigate }: MyGalleryTabProps) {
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<PosterMaterial | null>(
|
||||
null,
|
||||
);
|
||||
const [recentInsertHistory, setRecentInsertHistory] = useState<
|
||||
CanvasImageInsertHistoryEntry[]
|
||||
>(() => getVisibleInsertHistory(projectId));
|
||||
const pendingInsertRequestMetaRef = useRef<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
projectId: string;
|
||||
contentId: string | null;
|
||||
canvasType: CanvasImageTargetType;
|
||||
theme: string;
|
||||
imageTitle?: string;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
setRecentInsertHistory(getVisibleInsertHistory(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => {
|
||||
const pendingMeta = pendingInsertRequestMetaRef.current.get(ack.requestId);
|
||||
if (!pendingMeta) {
|
||||
return;
|
||||
}
|
||||
pendingInsertRequestMetaRef.current.delete(ack.requestId);
|
||||
|
||||
if (!ack.success) {
|
||||
toast.error("插图失败,请返回创作区重试");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHistory = addCanvasImageInsertHistory({
|
||||
requestId: ack.requestId,
|
||||
projectId: pendingMeta.projectId,
|
||||
contentId: pendingMeta.contentId,
|
||||
canvasType: pendingMeta.canvasType,
|
||||
theme: pendingMeta.theme,
|
||||
imageTitle: pendingMeta.imageTitle,
|
||||
locationLabel: ack.locationLabel,
|
||||
});
|
||||
setRecentInsertHistory(
|
||||
(projectId
|
||||
? nextHistory.filter((entry) => entry.projectId === projectId)
|
||||
: nextHistory
|
||||
).slice(0, 3),
|
||||
);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [projectId]);
|
||||
|
||||
const handleInsertFromGallery = (material: PosterMaterial) => {
|
||||
if (!projectId) {
|
||||
toast.error("请先选择项目");
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = material.filePath
|
||||
? convertFileSrc(material.filePath)
|
||||
: material.metadata?.thumbnail || "";
|
||||
if (!imageUrl) {
|
||||
toast.error("该素材缺少可用图片地址,无法插入");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = getActiveContentTarget();
|
||||
const sameProjectTarget = target?.projectId === projectId ? target : null;
|
||||
const targetContentId = sameProjectTarget?.contentId ?? null;
|
||||
const targetCanvasType = normalizeCanvasType(sameProjectTarget?.canvasType);
|
||||
const targetTheme = mapCanvasTypeToTheme(targetCanvasType);
|
||||
|
||||
const request = emitCanvasImageInsertRequest({
|
||||
projectId,
|
||||
contentId: targetContentId,
|
||||
canvasType: targetCanvasType,
|
||||
anchorHint:
|
||||
targetCanvasType === "video"
|
||||
? "video_start_frame"
|
||||
: targetCanvasType === "poster"
|
||||
? "poster_center"
|
||||
: "section_end",
|
||||
source: "gallery",
|
||||
image: {
|
||||
id: material.id,
|
||||
previewUrl: material.metadata?.thumbnail || imageUrl,
|
||||
contentUrl: imageUrl,
|
||||
title: material.name,
|
||||
width: material.metadata?.width,
|
||||
height: material.metadata?.height,
|
||||
attributionName: "项目素材库",
|
||||
provider: "gallery",
|
||||
},
|
||||
});
|
||||
pendingInsertRequestMetaRef.current.set(request.requestId, {
|
||||
projectId,
|
||||
contentId: targetContentId,
|
||||
canvasType: targetCanvasType,
|
||||
theme: targetTheme,
|
||||
imageTitle: material.name,
|
||||
});
|
||||
|
||||
onNavigate?.("agent", {
|
||||
projectId,
|
||||
contentId: targetContentId ?? undefined,
|
||||
theme: targetTheme,
|
||||
lockTheme: false,
|
||||
});
|
||||
toast.success("已发送到当前画布,正在自动定位");
|
||||
};
|
||||
|
||||
const handleRelocate = (entry: CanvasImageInsertHistoryEntry) => {
|
||||
onNavigate?.("agent", {
|
||||
projectId: entry.projectId,
|
||||
contentId: entry.contentId ?? undefined,
|
||||
theme: entry.theme,
|
||||
lockTheme: false,
|
||||
});
|
||||
toast.success("正在定位到插图位置");
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<Container>
|
||||
<EmptyState>
|
||||
<EmptyIcon>
|
||||
<Images size={28} />
|
||||
</EmptyIcon>
|
||||
<EmptyTitle>请先选择项目</EmptyTitle>
|
||||
<EmptyHint>在右上角选择一个项目后即可查看图片库</EmptyHint>
|
||||
</EmptyState>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ActionBar>
|
||||
<ActionHint>
|
||||
{selectedMaterial
|
||||
? `已选中:${selectedMaterial.name}`
|
||||
: "可双击图片直接插入当前画布,或先单击选中后点击右侧按钮"}
|
||||
</ActionHint>
|
||||
<div className="flex items-center gap-2">
|
||||
{recentInsertHistory[0] && (
|
||||
<RelocateButton
|
||||
type="button"
|
||||
onClick={() => handleRelocate(recentInsertHistory[0])}
|
||||
>
|
||||
再次定位
|
||||
</RelocateButton>
|
||||
)}
|
||||
<InsertButton
|
||||
type="button"
|
||||
disabled={!selectedMaterial}
|
||||
onClick={() => {
|
||||
if (!selectedMaterial) {
|
||||
return;
|
||||
}
|
||||
handleInsertFromGallery(selectedMaterial);
|
||||
}}
|
||||
>
|
||||
插入选中图片到当前画布
|
||||
</InsertButton>
|
||||
</div>
|
||||
</ActionBar>
|
||||
<ImageGallery
|
||||
projectId={projectId}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
selectedIds={selectedMaterial ? [selectedMaterial.id] : []}
|
||||
onSelect={(materials) => {
|
||||
setSelectedMaterial(materials[0] || null);
|
||||
}}
|
||||
onDoubleClick={handleInsertFromGallery}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyGalleryTab;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file tabs 导出
|
||||
* @module components/image-gen/tabs
|
||||
*/
|
||||
|
||||
export { AiImageGenTab } from "./AiImageGenTab";
|
||||
export type { AiImageGenTabProps } from "./AiImageGenTab";
|
||||
|
||||
export { ImageSearchTab } from "./ImageSearchTab";
|
||||
export type { ImageSearchTabProps } from "./ImageSearchTab";
|
||||
|
||||
export { LocalImageTab } from "./LocalImageTab";
|
||||
export type { LocalImageTabProps } from "./LocalImageTab";
|
||||
|
||||
export { MyGalleryTab } from "./MyGalleryTab";
|
||||
export type { MyGalleryTabProps } from "./MyGalleryTab";
|
||||
@@ -7,17 +7,10 @@ import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { WelcomeStep } from "./steps/WelcomeStep";
|
||||
import { UserProfileStep } from "./steps/UserProfileStep";
|
||||
import { PluginSelectStep } from "./steps/PluginSelectStep";
|
||||
import {
|
||||
InstallProgressStep,
|
||||
type PluginInstallState,
|
||||
} from "./steps/InstallProgressStep";
|
||||
import { VoiceShortcutTestStep } from "./steps/VoiceShortcutTestStep";
|
||||
import { MicrophoneTestStep } from "./steps/MicrophoneTestStep";
|
||||
import { VoiceDemoStep } from "./steps/VoiceDemoStep";
|
||||
import { CompleteStep } from "./steps/CompleteStep";
|
||||
import { userProfiles, type UserProfile } from "./constants";
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
@@ -86,7 +79,7 @@ const FooterRight = styled.div`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const TOTAL_STEPS = 8;
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: () => void;
|
||||
@@ -94,11 +87,6 @@ interface OnboardingWizardProps {
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<string[]>([]);
|
||||
const [installResults, setInstallResults] = useState<PluginInstallState[]>(
|
||||
[],
|
||||
);
|
||||
const [voiceShortcut, setVoiceShortcut] = useState(
|
||||
"CommandOrControl+Shift+V",
|
||||
);
|
||||
@@ -118,85 +106,54 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 当用户选择群体时,自动选中默认插件
|
||||
useEffect(() => {
|
||||
if (userProfile) {
|
||||
const profile = userProfiles.find((p) => p.id === userProfile);
|
||||
if (profile) {
|
||||
setSelectedPlugins(profile.defaultPlugins);
|
||||
}
|
||||
}
|
||||
}, [userProfile]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
// 如果没有选择插件,跳过安装步骤
|
||||
if (currentStep === 3 && selectedPlugins.length === 0) {
|
||||
// 跳到语音快捷键测试(如果语音功能启用)或完成页
|
||||
setCurrentStep(voiceEnabled ? 5 : 8);
|
||||
} else if (currentStep === 4) {
|
||||
// 安装完成后,跳到语音快捷键测试(如果语音功能启用)或完成页
|
||||
// 注意:这个分支不会被执行,因为 InstallProgressStep 会调用 handleInstallComplete
|
||||
// 如果语音功能未启用,直接跳到完成页
|
||||
if (currentStep === 1 && !voiceEnabled) {
|
||||
setCurrentStep(5);
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
}, [currentStep, selectedPlugins, voiceEnabled]);
|
||||
}, [currentStep, voiceEnabled]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
// 如果从完成页返回
|
||||
if (currentStep === 8) {
|
||||
// 返回到语音演示或插件选择
|
||||
setCurrentStep(voiceEnabled ? 7 : 3);
|
||||
} else if (currentStep === 5 && installResults.length === 0) {
|
||||
// 从语音快捷键测试返回到插件选择
|
||||
setCurrentStep(3);
|
||||
} else if (currentStep === 5 && installResults.length > 0) {
|
||||
// 已经安装过,不能返回到安装步骤
|
||||
return;
|
||||
if (currentStep === 5) {
|
||||
// 返回到语音演示或欢迎页
|
||||
setCurrentStep(voiceEnabled ? 4 : 1);
|
||||
} else {
|
||||
setCurrentStep((prev) => prev - 1);
|
||||
}
|
||||
}
|
||||
}, [currentStep, installResults, voiceEnabled]);
|
||||
}, [currentStep, voiceEnabled]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
const handleInstallComplete = useCallback(
|
||||
(results: PluginInstallState[]) => {
|
||||
setInstallResults(results);
|
||||
// 安装完成后,跳到语音快捷键测试(如果语音功能启用)或完成页
|
||||
setCurrentStep(voiceEnabled ? 5 : 8);
|
||||
},
|
||||
[voiceEnabled],
|
||||
);
|
||||
|
||||
// 语音快捷键测试成功
|
||||
const handleShortcutTestSuccess = useCallback(() => {
|
||||
setCurrentStep(6);
|
||||
setCurrentStep(3);
|
||||
}, []);
|
||||
|
||||
// 麦克风测试成功
|
||||
const handleMicTestSuccess = useCallback(() => {
|
||||
setCurrentStep(7);
|
||||
setCurrentStep(4);
|
||||
}, []);
|
||||
|
||||
// 语音演示完成
|
||||
const handleVoiceDemoSuccess = useCallback(() => {
|
||||
setCurrentStep(8);
|
||||
setCurrentStep(5);
|
||||
}, []);
|
||||
|
||||
// 跳过语音测试
|
||||
const handleSkipVoiceTest = useCallback(() => {
|
||||
setCurrentStep(8);
|
||||
setCurrentStep(5);
|
||||
}, []);
|
||||
|
||||
const handleFinish = useCallback(() => {
|
||||
// 触发插件变化事件,刷新侧边栏
|
||||
window.dispatchEvent(new CustomEvent("plugin-changed"));
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
@@ -206,28 +163,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
case 1:
|
||||
return <WelcomeStep onNext={handleNext} onSkip={handleSkip} />;
|
||||
case 2:
|
||||
return (
|
||||
<UserProfileStep
|
||||
selectedProfile={userProfile}
|
||||
onSelect={setUserProfile}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<PluginSelectStep
|
||||
userProfile={userProfile}
|
||||
selectedPlugins={selectedPlugins}
|
||||
onSelectionChange={setSelectedPlugins}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<InstallProgressStep
|
||||
selectedPlugins={selectedPlugins}
|
||||
onComplete={handleInstallComplete}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<VoiceShortcutTestStep
|
||||
shortcut={voiceShortcut}
|
||||
@@ -235,51 +170,35 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 6:
|
||||
case 3:
|
||||
return (
|
||||
<MicrophoneTestStep
|
||||
onSuccess={handleMicTestSuccess}
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 7:
|
||||
case 4:
|
||||
return (
|
||||
<VoiceDemoStep
|
||||
onSuccess={handleVoiceDemoSuccess}
|
||||
onSkip={handleSkipVoiceTest}
|
||||
/>
|
||||
);
|
||||
case 8:
|
||||
return (
|
||||
<CompleteStep
|
||||
installResults={installResults}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
);
|
||||
case 5:
|
||||
return <CompleteStep onFinish={handleFinish} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断下一步按钮是否可用
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 2:
|
||||
return userProfile !== null;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否显示底部导航
|
||||
// 不显示底部导航的步骤:欢迎页、安装进度、语音测试步骤、完成页
|
||||
// 不显示底部导航的步骤:欢迎页、语音测试步骤、完成页
|
||||
const showFooter =
|
||||
currentStep !== 1 &&
|
||||
currentStep !== 2 &&
|
||||
currentStep !== 3 &&
|
||||
currentStep !== 4 &&
|
||||
currentStep !== 5 &&
|
||||
currentStep !== 6 &&
|
||||
currentStep !== 7 &&
|
||||
currentStep !== 8;
|
||||
currentStep !== 5;
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
@@ -310,12 +229,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
<Button variant="outline" onClick={handleSkip}>
|
||||
跳过
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed()}>
|
||||
{currentStep === 3
|
||||
? selectedPlugins.length > 0
|
||||
? "开始安装"
|
||||
: "跳过安装"
|
||||
: "下一步"}
|
||||
<Button onClick={handleNext}>
|
||||
下一步
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</FooterRight>
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, XCircle, PartyPopper } from "lucide-react";
|
||||
import { onboardingPlugins } from "../constants";
|
||||
import type { PluginInstallState } from "./InstallProgressStep";
|
||||
import { PartyPopper } from "lucide-react";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -47,59 +45,17 @@ const Subtitle = styled.p`
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const ResultList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const ResultItem = styled.div<{ $success: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3% / 0.1)" : "hsl(0 84.2% 60.2% / 0.1)"};
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultText = styled.span<{ $success: boolean }>`
|
||||
font-size: 14px;
|
||||
color: ${({ $success }) =>
|
||||
$success ? "hsl(142.1 76.2% 36.3%)" : "hsl(0 84.2% 60.2%)"};
|
||||
`;
|
||||
|
||||
const NoPluginsMessage = styled.p`
|
||||
const TipsMessage = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
interface CompleteStepProps {
|
||||
installResults: PluginInstallState[];
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ installResults, onFinish }: CompleteStepProps) {
|
||||
const successCount = installResults.filter(
|
||||
(r) => r.status === "complete",
|
||||
).length;
|
||||
const failedCount = installResults.filter(
|
||||
(r) => r.status === "failed",
|
||||
).length;
|
||||
const hasResults = installResults.length > 0;
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
return (
|
||||
<Container>
|
||||
<IconContainer>
|
||||
@@ -107,40 +63,11 @@ export function CompleteStep({ installResults, onFinish }: CompleteStepProps) {
|
||||
</IconContainer>
|
||||
|
||||
<Title>设置完成!</Title>
|
||||
<Subtitle>
|
||||
{hasResults
|
||||
? successCount === installResults.length
|
||||
? "所有插件已成功安装,您可以开始使用 ProxyCast 了。"
|
||||
: `已安装 ${successCount} 个插件${failedCount > 0 ? `,${failedCount} 个安装失败` : ""}。您可以稍后在插件中心重试。`
|
||||
: "您已跳过插件安装,可以稍后在插件中心安装需要的插件。"}
|
||||
</Subtitle>
|
||||
<Subtitle>ProxyCast 已准备就绪,您可以开始使用了。</Subtitle>
|
||||
|
||||
{hasResults && (
|
||||
<ResultList>
|
||||
{installResults.map((result) => {
|
||||
const plugin = onboardingPlugins.find(
|
||||
(p) => p.id === result.pluginId,
|
||||
);
|
||||
const isSuccess = result.status === "complete";
|
||||
|
||||
return (
|
||||
<ResultItem key={result.pluginId} $success={isSuccess}>
|
||||
{isSuccess ? <CheckCircle /> : <XCircle />}
|
||||
<ResultText $success={isSuccess}>
|
||||
{plugin?.name || result.pluginId}
|
||||
{isSuccess ? " 安装成功" : " 安装失败"}
|
||||
</ResultText>
|
||||
</ResultItem>
|
||||
);
|
||||
})}
|
||||
</ResultList>
|
||||
)}
|
||||
|
||||
{!hasResults && (
|
||||
<NoPluginsMessage>
|
||||
提示:您可以在左侧导航栏的"插件中心"随时安装插件
|
||||
</NoPluginsMessage>
|
||||
)}
|
||||
<TipsMessage>
|
||||
提示:您可以在左侧导航栏的"插件中心"随时安装插件
|
||||
</TipsMessage>
|
||||
|
||||
<Button size="lg" onClick={onFinish}>
|
||||
开始使用
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
onboardingPlugins,
|
||||
userProfiles,
|
||||
type UserProfile,
|
||||
} from "../constants";
|
||||
import { onboardingPlugins } from "../constants";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@@ -101,17 +97,6 @@ const PluginDescription = styled.div`
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const RecommendBadge = styled.span`
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const SelectAllRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -134,20 +119,14 @@ const SelectAllButton = styled.button`
|
||||
`;
|
||||
|
||||
interface PluginSelectStepProps {
|
||||
userProfile: UserProfile | null;
|
||||
selectedPlugins: string[];
|
||||
onSelectionChange: (plugins: string[]) => void;
|
||||
}
|
||||
|
||||
export function PluginSelectStep({
|
||||
userProfile,
|
||||
selectedPlugins,
|
||||
onSelectionChange,
|
||||
}: PluginSelectStepProps) {
|
||||
// 获取当前用户群体的默认插件
|
||||
const defaultPlugins =
|
||||
userProfiles.find((p) => p.id === userProfile)?.defaultPlugins || [];
|
||||
|
||||
const handleToggle = (pluginId: string) => {
|
||||
if (selectedPlugins.includes(pluginId)) {
|
||||
onSelectionChange(selectedPlugins.filter((id) => id !== pluginId));
|
||||
@@ -169,11 +148,7 @@ export function PluginSelectStep({
|
||||
return (
|
||||
<Container>
|
||||
<Title>选择要安装的插件</Title>
|
||||
<Subtitle>
|
||||
{userProfile === "developer"
|
||||
? "已为程序员推荐配置管理和 Flow Monitor 插件"
|
||||
: "您可以根据需要选择插件,或稍后在插件中心安装"}
|
||||
</Subtitle>
|
||||
<Subtitle>您可以根据需要选择插件,或稍后在插件中心安装</Subtitle>
|
||||
|
||||
<SelectAllRow>
|
||||
<SelectAllButton onClick={handleSelectAll}>
|
||||
@@ -184,7 +159,6 @@ export function PluginSelectStep({
|
||||
<PluginList>
|
||||
{onboardingPlugins.map((plugin) => {
|
||||
const isSelected = selectedPlugins.includes(plugin.id);
|
||||
const isRecommended = defaultPlugins.includes(plugin.id);
|
||||
const Icon = plugin.icon;
|
||||
|
||||
return (
|
||||
@@ -203,10 +177,7 @@ export function PluginSelectStep({
|
||||
<Icon />
|
||||
</IconWrapper>
|
||||
<PluginInfo>
|
||||
<PluginName>
|
||||
{plugin.name}
|
||||
{isRecommended && <RecommendBadge>推荐</RecommendBadge>}
|
||||
</PluginName>
|
||||
<PluginName>{plugin.name}</PluginName>
|
||||
<PluginDescription>{plugin.description}</PluginDescription>
|
||||
</PluginInfo>
|
||||
</PluginCard>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* 初次安装引导 - 用户群体选择
|
||||
*/
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Check } from "lucide-react";
|
||||
import { userProfiles, type UserProfile } from "../constants";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ProfileGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
const ProfileCard = styled.button<{ $selected?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--border))"};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary) / 0.05)" : "hsl(var(--card))"};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--primary) / 0.5)"};
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckBadge = styled.div`
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.div<{ $selected?: boolean }>`
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary))" : "hsl(var(--muted))"};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: ${({ $selected }) =>
|
||||
$selected ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"};
|
||||
}
|
||||
`;
|
||||
|
||||
const ProfileName = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const ProfileDescription = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
interface UserProfileStepProps {
|
||||
selectedProfile: UserProfile | null;
|
||||
onSelect: (profile: UserProfile) => void;
|
||||
}
|
||||
|
||||
export function UserProfileStep({
|
||||
selectedProfile,
|
||||
onSelect,
|
||||
}: UserProfileStepProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Title>您是哪类用户?</Title>
|
||||
<Subtitle>我们将根据您的选择推荐合适的插件</Subtitle>
|
||||
|
||||
<ProfileGrid>
|
||||
{userProfiles.map((profile) => {
|
||||
const isSelected = selectedProfile === profile.id;
|
||||
const Icon = profile.icon;
|
||||
|
||||
return (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
$selected={isSelected}
|
||||
onClick={() => onSelect(profile.id)}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckBadge>
|
||||
<Check />
|
||||
</CheckBadge>
|
||||
)}
|
||||
<IconWrapper $selected={isSelected}>
|
||||
<Icon />
|
||||
</IconWrapper>
|
||||
<ProfileName>{profile.name}</ProfileName>
|
||||
<ProfileDescription>{profile.description}</ProfileDescription>
|
||||
</ProfileCard>
|
||||
);
|
||||
})}
|
||||
</ProfileGrid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,20 @@
|
||||
|
||||
import styled from "styled-components";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Cpu, Puzzle, Mic } from "lucide-react";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
padding: 40px 24px;
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const LogoContainer = styled.div`
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 32px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
|
||||
@keyframes float {
|
||||
@@ -39,49 +38,88 @@ const Logo = styled.img`
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 18px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 48px;
|
||||
margin-bottom: 32px;
|
||||
`;
|
||||
|
||||
const FeatureList = styled.div`
|
||||
const FeatureGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: auto;
|
||||
`;
|
||||
|
||||
const FeatureCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 48px;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
padding: 20px 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const FeatureItem = styled.div`
|
||||
const FeatureIcon = styled.div`
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeatureTitle = styled.h3`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const FeatureDescription = styled.p`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding-top: 16px;
|
||||
`;
|
||||
|
||||
interface WelcomeStepProps {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
@@ -95,32 +133,48 @@ export function WelcomeStep({ onNext, onSkip }: WelcomeStepProps) {
|
||||
</LogoContainer>
|
||||
|
||||
<Title>欢迎使用 ProxyCast</Title>
|
||||
<Subtitle>AI API 聚合代理</Subtitle>
|
||||
<Description>
|
||||
让我们花一分钟时间,根据您的使用场景推荐合适的插件,提升您的使用体验。
|
||||
</Description>
|
||||
<Subtitle>
|
||||
ProxyCast 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。
|
||||
</Subtitle>
|
||||
|
||||
<FeatureList>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>多凭证池管理,自动轮换</span>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>支持 Claude、OpenAI、Gemini 等主流 API</span>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Sparkles />
|
||||
<span>插件扩展,按需安装</span>
|
||||
</FeatureItem>
|
||||
</FeatureList>
|
||||
<FeatureGrid>
|
||||
<FeatureCard>
|
||||
<FeatureIcon>
|
||||
<Cpu />
|
||||
</FeatureIcon>
|
||||
<FeatureTitle>多模型支持</FeatureTitle>
|
||||
<FeatureDescription>
|
||||
支持 Claude、OpenAI、Gemini 等主流 AI 模型
|
||||
</FeatureDescription>
|
||||
</FeatureCard>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<FeatureCard>
|
||||
<FeatureIcon>
|
||||
<Puzzle />
|
||||
</FeatureIcon>
|
||||
<FeatureTitle>插件扩展</FeatureTitle>
|
||||
<FeatureDescription>
|
||||
丰富的插件生态,按需安装扩展功能
|
||||
</FeatureDescription>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard>
|
||||
<FeatureIcon>
|
||||
<Mic />
|
||||
</FeatureIcon>
|
||||
<FeatureTitle>语音交互</FeatureTitle>
|
||||
<FeatureDescription>
|
||||
便捷的语音输入,提升对话效率
|
||||
</FeatureDescription>
|
||||
</FeatureCard>
|
||||
</FeatureGrid>
|
||||
|
||||
<Footer>
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
跳过引导
|
||||
</Button>
|
||||
<Button onClick={onNext}>开始设置</Button>
|
||||
</div>
|
||||
<Button onClick={onNext}>开始使用</Button>
|
||||
</Footer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shar
|
||||
import { AppearanceSettings } from '../general/appearance';
|
||||
import { ChatAppearanceSettings } from '../general/chat-appearance';
|
||||
import { MemorySettings } from "../general/memory";
|
||||
// 网络代理
|
||||
import { ProxySettings } from "../system/proxy";
|
||||
// 安全与性能
|
||||
import { SecurityPerformanceSettings } from "../system/security-performance";
|
||||
// 心跳引擎
|
||||
@@ -246,14 +244,6 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
|
||||
</>
|
||||
);
|
||||
|
||||
case SettingsTabs.Proxy:
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title="网络代理" />
|
||||
<ProxySettings />
|
||||
</>
|
||||
);
|
||||
|
||||
case SettingsTabs.SecurityPerformance:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockGetConfig, mockSaveConfig } = vi.hoisted(() => ({
|
||||
mockGetConfig: vi.fn(),
|
||||
mockSaveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTauri", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
}));
|
||||
|
||||
import { ImageGenSettings } from ".";
|
||||
|
||||
interface Mounted {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
function renderComponent(): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<ImageGenSettings />);
|
||||
});
|
||||
mounted.push({ container, root });
|
||||
return container;
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetConfig.mockResolvedValue({
|
||||
image_gen: {
|
||||
default_service: "dall_e",
|
||||
default_count: 1,
|
||||
default_size: "1024x1024",
|
||||
default_quality: "standard",
|
||||
default_style: "vivid",
|
||||
enable_enhancement: false,
|
||||
auto_download: false,
|
||||
image_search_pexels_api_key: "old-key",
|
||||
},
|
||||
});
|
||||
mockSaveConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
const target = mounted.pop();
|
||||
if (!target) break;
|
||||
act(() => {
|
||||
target.root.unmount();
|
||||
});
|
||||
target.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ImageGenSettings", () => {
|
||||
it("应加载图像生成配置", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
expect(container.textContent).toContain("默认图像生成服务");
|
||||
expect(container.textContent).toContain("默认图像数量");
|
||||
});
|
||||
|
||||
it("修改默认图像数量后应调用保存配置", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "3").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockSaveConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image_gen: expect.objectContaining({
|
||||
default_count: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("设置已保存");
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ const DEFAULT_ENABLED_THEMES = [
|
||||
const ALL_NAV_ITEMS = [
|
||||
{ id: "home-general", label: "首页" },
|
||||
{ id: "video", label: "视频" },
|
||||
{ id: "image-gen", label: "绘画" },
|
||||
{ id: "image-gen", label: "插图" },
|
||||
{ id: "batch", label: "批量任务" },
|
||||
{ id: "plugins", label: "插件中心" },
|
||||
] as const;
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Route,
|
||||
Search,
|
||||
Monitor,
|
||||
Globe,
|
||||
ShieldCheck,
|
||||
HeartPulse,
|
||||
Activity,
|
||||
@@ -174,11 +173,6 @@ export function useSettingsCategory(): CategoryGroup[] {
|
||||
label: t("settings.tab.chromeRelay", "Chrome Relay"),
|
||||
icon: Monitor,
|
||||
},
|
||||
{
|
||||
key: SettingsTabs.Proxy,
|
||||
label: t("settings.tab.proxy", "网络代理"),
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
key: SettingsTabs.SecurityPerformance,
|
||||
label: t("settings.tab.securityPerformance", "安全与性能"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
|
||||
@@ -22,13 +23,34 @@ interface DownloadResult {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
interface ToolVersion {
|
||||
name: string;
|
||||
version: string | null;
|
||||
installed: boolean;
|
||||
}
|
||||
const CREATIVE_THEMES = [
|
||||
"通用对话",
|
||||
"社媒内容",
|
||||
"图文海报",
|
||||
"歌词曲谱",
|
||||
"知识探索",
|
||||
"计划规划",
|
||||
"办公文档",
|
||||
"短视频",
|
||||
"小说创作",
|
||||
] as const;
|
||||
|
||||
const QUICK_START_STEPS = [
|
||||
"选主题:按目标进入对应创作主题",
|
||||
"给输入:一句需求、一个方向或一份素材都可以",
|
||||
"持续迭代:边聊边改边沉淀,最终得到可发布结果",
|
||||
] as const;
|
||||
|
||||
const TARGET_USERS = [
|
||||
"自媒体创作者",
|
||||
"短视频团队",
|
||||
"小说与剧情创作者",
|
||||
"运营与品牌内容团队",
|
||||
"需要长期沉淀创作资产的个人与小团队",
|
||||
] as const;
|
||||
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslation();
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
|
||||
current: "",
|
||||
latest: undefined,
|
||||
@@ -41,8 +63,6 @@ export function AboutSection() {
|
||||
const [downloadResult, setDownloadResult] = useState<DownloadResult | null>(
|
||||
null,
|
||||
);
|
||||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
||||
const [loadingTools, setLoadingTools] = useState(true);
|
||||
|
||||
// 加载当前版本号(从后端获取,确保与 Cargo.toml 同步)
|
||||
useEffect(() => {
|
||||
@@ -61,21 +81,6 @@ export function AboutSection() {
|
||||
loadCurrentVersion();
|
||||
}, []);
|
||||
|
||||
// 加载本地工具版本
|
||||
useEffect(() => {
|
||||
const loadToolVersions = async () => {
|
||||
try {
|
||||
const versions = await safeInvoke<ToolVersion[]>("get_tool_versions");
|
||||
setToolVersions(versions);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tool versions:", error);
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
};
|
||||
loadToolVersions();
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setChecking(true);
|
||||
setDownloadResult(null);
|
||||
@@ -86,7 +91,7 @@ export function AboutSection() {
|
||||
console.error("Failed to check for updates:", error);
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
error: "检查更新失败",
|
||||
error: t("检查更新失败", "检查更新失败"),
|
||||
}));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
@@ -105,7 +110,10 @@ export function AboutSection() {
|
||||
setTimeout(() => {
|
||||
setDownloadResult({
|
||||
...result,
|
||||
message: "安装程序已启动,应用将自动关闭以完成更新",
|
||||
message: t(
|
||||
"安装程序已启动,应用将自动关闭以完成更新",
|
||||
"安装程序已启动,应用将自动关闭以完成更新",
|
||||
),
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
@@ -116,7 +124,7 @@ export function AboutSection() {
|
||||
console.error("Failed to download update:", error);
|
||||
setDownloadResult({
|
||||
success: false,
|
||||
message: "下载失败,请手动下载",
|
||||
message: t("下载失败,请手动下载", "下载失败,请手动下载"),
|
||||
filePath: undefined,
|
||||
});
|
||||
} finally {
|
||||
@@ -134,14 +142,30 @@ export function AboutSection() {
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">ProxyCast</h2>
|
||||
<p className="text-sm text-muted-foreground">AI API 代理服务</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("创作类 AI Agent 平台", "创作类 AI Agent 平台")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t(
|
||||
"把灵感、写作、出图、改稿、沉淀放进同一个工作台",
|
||||
"把灵感、写作、出图、改稿、沉淀放进同一个工作台",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-sm">版本 {versionInfo.current}</span>
|
||||
<span className="text-sm">
|
||||
{t("版本 {{version}}", {
|
||||
version: versionInfo.current,
|
||||
defaultValue: "版本 {{version}}",
|
||||
})}
|
||||
</span>
|
||||
{versionInfo.hasUpdate ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
|
||||
有新版本 {versionInfo.latest}
|
||||
{t("有新版本 {{version}}", {
|
||||
version: versionInfo.latest ?? "",
|
||||
defaultValue: "有新版本 {{version}}",
|
||||
})}
|
||||
</span>
|
||||
) : versionInfo.error ? (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||
@@ -151,7 +175,7 @@ export function AboutSection() {
|
||||
) : versionInfo.latest ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
已是最新
|
||||
{t("已是最新", "已是最新")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -165,7 +189,7 @@ export function AboutSection() {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
检查更新
|
||||
{t("检查更新", "检查更新")}
|
||||
</button>
|
||||
|
||||
{versionInfo.hasUpdate && (
|
||||
@@ -178,7 +202,9 @@ export function AboutSection() {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${downloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{downloading ? "下载中..." : "下载更新"}
|
||||
{downloading
|
||||
? t("下载中...", "下载中...")
|
||||
: t("下载更新", "下载更新")}
|
||||
</button>
|
||||
|
||||
{versionInfo.downloadUrl && (
|
||||
@@ -189,7 +215,7 @@ export function AboutSection() {
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
网页下载
|
||||
{t("网页下载", "网页下载")}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
@@ -218,10 +244,10 @@ export function AboutSection() {
|
||||
href={versionInfo.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 underline hover:no-underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
前往网页下载
|
||||
className="inline-flex items-center gap-1 mt-2 underline hover:no-underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("前往网页下载", "前往网页下载")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -232,7 +258,7 @@ export function AboutSection() {
|
||||
|
||||
{/* 链接 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">相关链接</h3>
|
||||
<h3 className="text-sm font-medium">{t("相关链接", "相关链接")}</h3>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="https://github.com/aiclientproxy/proxycast"
|
||||
@@ -240,7 +266,7 @@ export function AboutSection() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">GitHub 仓库</span>
|
||||
<span className="text-sm">{t("GitHub 仓库", "GitHub 仓库")}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
@@ -249,7 +275,7 @@ export function AboutSection() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">文档</span>
|
||||
<span className="text-sm">{t("文档", "文档")}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
@@ -258,105 +284,106 @@ export function AboutSection() {
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">问题反馈</span>
|
||||
<span className="text-sm">{t("问题反馈", "问题反馈")}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 Q&A */}
|
||||
{/* 产品定位 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">使用说明</h3>
|
||||
<div className="space-y-2">
|
||||
<QAItem
|
||||
question="ProxyCast 是什么?"
|
||||
answer="ProxyCast 是一个 AI Agent 工作台,并提供可选的内网团队共享网关。你可以本机直连使用,也可以把统一的 OpenAI/Anthropic 兼容接口分发给同网段同事。"
|
||||
/>
|
||||
<QAItem
|
||||
question="如何开始使用?"
|
||||
answer="1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 直接在「AI Agent」开始使用(默认推荐);3. 如需给团队共享,在「团队共享网关(内网)」开启共享并选择默认 Provider;4. 在内网工具中配置 API 地址(如 http://localhost:8999)"
|
||||
/>
|
||||
<QAItem
|
||||
question="什么是配置切换?"
|
||||
answer="配置切换可以一键修改 Claude Code、Codex、Gemini CLI 的配置文件,快速在不同 Provider 间切换。添加 ProxyCast 配置后,这些工具就会使用本地代理服务。"
|
||||
/>
|
||||
<QAItem
|
||||
question="凭证文件在哪里?"
|
||||
answer="Kiro 凭证:~/.kiro/kiro_creds.json;Gemini CLI 凭证:~/.gemini/oauth_creds.json;Qwen 凭证:~/.qwen-coder/auth.json"
|
||||
/>
|
||||
<QAItem
|
||||
question="支持哪些 AI 工具?"
|
||||
answer="支持所有兼容 OpenAI API 或 Anthropic API 的工具,如 Claude Code、Cursor、Cherry Studio、Continue、Cline 等。"
|
||||
/>
|
||||
<h3 className="text-sm font-medium">{t("产品定位", "产品定位")}</h3>
|
||||
<div className="p-4 rounded-lg border space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。",
|
||||
"ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{t("一句话:", "一句话:")}</span>
|
||||
{t("从“想到”直接走到“可发布”。", "从“想到”直接走到“可发布”。")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地工具版本 */}
|
||||
{/* 创作主题 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">本地工具版本</h3>
|
||||
<div className="p-4 rounded-lg border space-y-3">
|
||||
{loadingTools ? (
|
||||
<>
|
||||
<ToolVersionItem name="Claude Code" version="检测中..." />
|
||||
<ToolVersionItem name="Codex" version="检测中..." />
|
||||
<ToolVersionItem name="Gemini CLI" version="检测中..." />
|
||||
</>
|
||||
) : (
|
||||
toolVersions.map((tool) => (
|
||||
<ToolVersionItem
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
version={tool.installed ? tool.version || "已安装" : "未安装"}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("支持的创作主题", "支持的创作主题")}
|
||||
</h3>
|
||||
<div className="p-4 rounded-lg border">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CREATIVE_THEMES.map((theme) => (
|
||||
<span
|
||||
key={theme}
|
||||
className="px-2.5 py-1 rounded-full bg-muted text-sm text-muted-foreground"
|
||||
>
|
||||
{t(theme, theme)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速开始 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("3 步开始创作", "3 步开始创作")}
|
||||
</h3>
|
||||
<div className="p-4 rounded-lg border">
|
||||
<ol className="space-y-2 list-decimal pl-5 text-sm text-muted-foreground">
|
||||
{QUICK_START_STEPS.map((step) => (
|
||||
<li key={step}>{t(step, step)}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合谁 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("适合谁", "适合谁")}</h3>
|
||||
<div className="p-4 rounded-lg border">
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{TARGET_USERS.map((user) => (
|
||||
<li key={user} className="flex items-center gap-2">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
<span>{t(user, user)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可选能力说明 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("可选能力", "可选能力")}</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border p-3 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。",
|
||||
"团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。",
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。",
|
||||
"常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="text-center text-xs text-muted-foreground pt-4 border-t">
|
||||
<p>Made with love for AI developers</p>
|
||||
<p>
|
||||
{t(
|
||||
"Made with love for creators & builders",
|
||||
"Made with love for creators & builders",
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1">2025-2026 ProxyCast</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolVersionItem({ name, version }: { name: string; version: string }) {
|
||||
const isInstalled = version !== "未安装" && !version.includes("检测");
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isInstalled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QAItem({ question, answer }: { question: string; answer: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between p-3 text-left hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm font-medium">{question}</span>
|
||||
<span className="text-muted-foreground">{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 text-sm text-muted-foreground">{answer}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,81 @@
|
||||
* @file DeveloperSettings.tsx
|
||||
* @description 开发者设置页面 - 组件视图调试等开发工具
|
||||
*/
|
||||
import { Code2, Eye } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Bug, Code2, Eye } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useComponentDebug } from "@/contexts/ComponentDebugContext";
|
||||
import { getConfig, getLogs } from "@/hooks/useTauri";
|
||||
import {
|
||||
buildCrashDiagnosticPayload,
|
||||
copyCrashDiagnosticToClipboard,
|
||||
exportCrashDiagnosticToJson,
|
||||
normalizeCrashReportingConfig,
|
||||
} from "@/lib/crashDiagnostic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DeveloperSettings() {
|
||||
const { enabled, setEnabled } = useComponentDebug();
|
||||
const [diagnosticBusy, setDiagnosticBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const buildDiagnosticPayload = useCallback(async () => {
|
||||
const [config, logs] = await Promise.all([getConfig(), getLogs()]);
|
||||
return buildCrashDiagnosticPayload({
|
||||
crashConfig: normalizeCrashReportingConfig(config.crash_reporting),
|
||||
logs,
|
||||
appVersion: import.meta.env.VITE_APP_VERSION,
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCopyDiagnostic = useCallback(async () => {
|
||||
setDiagnosticBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const payload = await buildDiagnosticPayload();
|
||||
await copyCrashDiagnosticToClipboard(payload);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "诊断信息已复制,可直接发给开发者",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} catch (err) {
|
||||
console.error("复制诊断信息失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "复制诊断信息失败",
|
||||
});
|
||||
} finally {
|
||||
setDiagnosticBusy(false);
|
||||
}
|
||||
}, [buildDiagnosticPayload]);
|
||||
|
||||
const handleExportDiagnostic = useCallback(async () => {
|
||||
setDiagnosticBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const payload = await buildDiagnosticPayload();
|
||||
exportCrashDiagnosticToJson(payload);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "诊断文件已导出,可发送给开发者",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} catch (err) {
|
||||
console.error("导出诊断信息失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "导出诊断信息失败",
|
||||
});
|
||||
} finally {
|
||||
setDiagnosticBusy(false);
|
||||
}
|
||||
}, [buildDiagnosticPayload]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -17,6 +86,20 @@ export function DeveloperSettings() {
|
||||
<h3 className="text-lg font-semibold">开发者工具</h3>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.type === "success"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 组件视图调试 */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -45,6 +128,46 @@ export function DeveloperSettings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 崩溃诊断入口 */}
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-rose-500/10 rounded-lg">
|
||||
<Bug className="w-5 h-5 text-rose-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">崩溃诊断日志(开发协作)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
用于定位 Windows 闪退与前端异常,包含最近 30 条 FrontendCrash 日志(DSN 自动脱敏)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyDiagnostic()}
|
||||
disabled={diagnosticBusy}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
diagnosticBusy && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
复制诊断信息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleExportDiagnostic()}
|
||||
disabled={diagnosticBusy}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
diagnosticBusy && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
导出诊断 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FlaskConical, Camera, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { FlaskConical, Camera, AlertTriangle, RefreshCw, Bug } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getExperimentalConfig,
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
validateShortcut,
|
||||
updateScreenshotShortcut,
|
||||
ExperimentalFeatures,
|
||||
getConfig,
|
||||
saveConfig,
|
||||
type Config,
|
||||
getLogs,
|
||||
type CrashReportingConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings";
|
||||
import { UpdateCheckSettings } from "./UpdateCheckSettings";
|
||||
@@ -24,6 +29,14 @@ import {
|
||||
saveVoiceInputConfig,
|
||||
VoiceInputConfig,
|
||||
} from "@/lib/api/asrProvider";
|
||||
import { applyCrashReportingSettings } from "@/lib/crashReporting";
|
||||
import {
|
||||
buildCrashDiagnosticPayload,
|
||||
copyCrashDiagnosticToClipboard,
|
||||
DEFAULT_CRASH_REPORTING_CONFIG,
|
||||
exportCrashDiagnosticToJson,
|
||||
normalizeCrashReportingConfig,
|
||||
} from "@/lib/crashDiagnostic";
|
||||
|
||||
// ============================================================
|
||||
// 组件
|
||||
@@ -33,8 +46,12 @@ export function ExperimentalSettings() {
|
||||
// 状态
|
||||
const [config, setConfig] = useState<ExperimentalFeatures | null>(null);
|
||||
const [voiceConfig, setVoiceConfig] = useState<VoiceInputConfig | null>(null);
|
||||
const [crashConfig, setCrashConfig] = useState<CrashReportingConfig>(
|
||||
DEFAULT_CRASH_REPORTING_CONFIG,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [diagnosticBusy, setDiagnosticBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
@@ -49,12 +66,16 @@ export function ExperimentalSettings() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [experimentalConfig, voiceInputConfig] = await Promise.all([
|
||||
const [experimentalConfig, voiceInputConfig, fullConfig] = await Promise.all([
|
||||
getExperimentalConfig(),
|
||||
getVoiceInputConfig(),
|
||||
getConfig(),
|
||||
]);
|
||||
setConfig(experimentalConfig);
|
||||
setVoiceConfig(voiceInputConfig);
|
||||
setCrashConfig(
|
||||
normalizeCrashReportingConfig(fullConfig.crash_reporting),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("加载实验室配置失败:", err);
|
||||
setError(err instanceof Error ? err.message : "加载配置失败");
|
||||
@@ -80,6 +101,7 @@ export function ExperimentalSettings() {
|
||||
sound_enabled: true,
|
||||
translate_instruction_id: "default",
|
||||
});
|
||||
setCrashConfig(DEFAULT_CRASH_REPORTING_CONFIG);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -179,6 +201,112 @@ export function ExperimentalSettings() {
|
||||
[],
|
||||
);
|
||||
|
||||
const persistCrashConfig = useCallback(async (next: CrashReportingConfig) => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const latestConfig = await getConfig();
|
||||
const normalized = normalizeCrashReportingConfig(next);
|
||||
const updatedConfig: Config = {
|
||||
...latestConfig,
|
||||
crash_reporting: normalized,
|
||||
};
|
||||
await saveConfig(updatedConfig);
|
||||
await applyCrashReportingSettings(normalized);
|
||||
setCrashConfig(normalized);
|
||||
setMessage({ type: "success", text: "崩溃上报配置已更新" });
|
||||
setTimeout(() => setMessage(null), 2000);
|
||||
} catch (err) {
|
||||
console.error("保存崩溃上报配置失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "保存崩溃上报配置失败",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCrashEnabledToggle = useCallback(() => {
|
||||
const nextConfig = {
|
||||
...crashConfig,
|
||||
enabled: !crashConfig.enabled,
|
||||
};
|
||||
void persistCrashConfig(nextConfig);
|
||||
}, [crashConfig, persistCrashConfig]);
|
||||
|
||||
const handleCrashFieldChange = useCallback(
|
||||
(
|
||||
field: keyof CrashReportingConfig,
|
||||
value: string | boolean | number | null,
|
||||
) => {
|
||||
setCrashConfig((previous) => ({
|
||||
...previous,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveCrashConfig = useCallback(() => {
|
||||
void persistCrashConfig(crashConfig);
|
||||
}, [crashConfig, persistCrashConfig]);
|
||||
|
||||
const buildDiagnosticPayload = useCallback(async () => {
|
||||
const logs = await getLogs();
|
||||
return buildCrashDiagnosticPayload({
|
||||
crashConfig,
|
||||
logs,
|
||||
appVersion: import.meta.env.VITE_APP_VERSION,
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
}, [crashConfig]);
|
||||
|
||||
const copyCrashDiagnostic = useCallback(async () => {
|
||||
setDiagnosticBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const payload = await buildDiagnosticPayload();
|
||||
await copyCrashDiagnosticToClipboard(payload);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "诊断信息已复制,可直接发给开发者",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} catch (err) {
|
||||
console.error("复制诊断信息失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "复制诊断信息失败",
|
||||
});
|
||||
} finally {
|
||||
setDiagnosticBusy(false);
|
||||
}
|
||||
}, [buildDiagnosticPayload]);
|
||||
|
||||
const exportCrashDiagnostic = useCallback(async () => {
|
||||
setDiagnosticBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const payload = await buildDiagnosticPayload();
|
||||
exportCrashDiagnosticToJson(payload);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "诊断文件已导出,可发送给开发者",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} catch (err) {
|
||||
console.error("导出诊断信息失败:", err);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : "导出诊断信息失败",
|
||||
});
|
||||
} finally {
|
||||
setDiagnosticBusy(false);
|
||||
}
|
||||
}, [buildDiagnosticPayload]);
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -315,6 +443,140 @@ export function ExperimentalSettings() {
|
||||
<UpdateCheckSettings />
|
||||
</div>
|
||||
|
||||
{/* 崩溃上报 */}
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Bug className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">崩溃上报(Sentry)</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
收集前端渲染错误与崩溃信息,用于定位 Windows 客诉闪退问题
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(crashConfig.enabled)}
|
||||
onChange={handleCrashEnabledToggle}
|
||||
disabled={saving}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
"bg-muted peer-checked:bg-primary",
|
||||
"after:content-[''] after:absolute after:top-0.5 after:left-0.5",
|
||||
"after:bg-white after:rounded-full after:h-4 after:w-4",
|
||||
"after:transition-transform peer-checked:after:translate-x-4",
|
||||
saving && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<label className="text-xs text-muted-foreground">DSN</label>
|
||||
<input
|
||||
value={crashConfig.dsn ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCrashFieldChange("dsn", event.target.value || null)
|
||||
}
|
||||
disabled={saving}
|
||||
placeholder="https://xxx@o0.ingest.sentry.io/0"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Environment</label>
|
||||
<input
|
||||
value={crashConfig.environment ?? "production"}
|
||||
onChange={(event) =>
|
||||
handleCrashFieldChange("environment", event.target.value)
|
||||
}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">采样率 (0-1)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={Number(crashConfig.sample_rate ?? 1)}
|
||||
onChange={(event) =>
|
||||
handleCrashFieldChange(
|
||||
"sample_rate",
|
||||
Number(event.target.value || 1),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(crashConfig.send_pii)}
|
||||
onChange={(event) =>
|
||||
handleCrashFieldChange("send_pii", event.target.checked)
|
||||
}
|
||||
disabled={saving}
|
||||
className="h-4 w-4 rounded border"
|
||||
/>
|
||||
发送默认 PII 字段(默认关闭)
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
关闭后仅写本地日志,不发送远端。若 DSN 为空,也会自动仅本地记录。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyCrashDiagnostic()}
|
||||
disabled={saving || diagnosticBusy}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
(saving || diagnosticBusy) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
复制诊断信息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void exportCrashDiagnostic()}
|
||||
disabled={saving || diagnosticBusy}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-xs transition-colors",
|
||||
(saving || diagnosticBusy) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
导出诊断 JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCrashConfig}
|
||||
disabled={saving || diagnosticBusy}
|
||||
className={cn(
|
||||
"rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition-colors",
|
||||
(saving || diagnosticBusy) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语音输入功能 */}
|
||||
{voiceConfig && (
|
||||
<div className="rounded-lg border p-4">
|
||||
|
||||
@@ -4,20 +4,9 @@ import {
|
||||
EyeOff,
|
||||
Copy,
|
||||
Check,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
Config,
|
||||
checkApiCompatibility,
|
||||
ApiCompatibilityResult,
|
||||
} from "@/hooks/useTauri";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
|
||||
export function ProxySettings() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
@@ -29,13 +18,6 @@ export function ProxySettings() {
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
// API Compatibility Check
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<ApiCompatibilityResult | null>(
|
||||
null,
|
||||
);
|
||||
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
@@ -72,46 +54,6 @@ export function ProxySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckApiCompatibility = async (provider: string) => {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const result = await checkApiCompatibility(provider);
|
||||
setCheckResult(result);
|
||||
setLastCheckTime(new Date());
|
||||
} catch (e) {
|
||||
setMessage({ type: "error", text: `API 检测失败: ${e}` });
|
||||
setTimeout(() => setMessage(null), 5000);
|
||||
}
|
||||
setChecking(false);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
||||
case "partial":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case "error":
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
return "所有模型可用";
|
||||
case "partial":
|
||||
return "部分模型可用";
|
||||
case "error":
|
||||
return "API 不可用";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -238,147 +180,6 @@ export function ProxySettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Code 兼容性检测 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-purple-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Claude Code 兼容性检测</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
检测 API 是否支持 Claude Code 所需的功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg border space-y-4">
|
||||
<div className="rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3 text-sm">
|
||||
<p className="font-medium text-purple-700 dark:text-purple-300">
|
||||
检测项目:
|
||||
</p>
|
||||
<ul className="mt-1 list-inside list-disc text-purple-600 dark:text-purple-400 text-xs">
|
||||
<li>基础对话能力 (basic)</li>
|
||||
<li>Tool Calls 支持 (tool_call) - Claude Code 核心功能</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleCheckApiCompatibility("kiro")}
|
||||
disabled={checking}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4" />
|
||||
)}
|
||||
检测 Kiro
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCheckApiCompatibility("gemini")}
|
||||
disabled={checking}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4" />
|
||||
)}
|
||||
检测 Gemini
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCheckApiCompatibility("qwen")}
|
||||
disabled={checking}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4" />
|
||||
)}
|
||||
检测 Qwen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastCheckTime && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最后检测时间: {lastCheckTime.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{checkResult && (
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(checkResult.overall_status)}
|
||||
<span className="font-medium">
|
||||
{checkResult.provider.toUpperCase()} -{" "}
|
||||
{getStatusText(checkResult.overall_status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(checkResult.checked_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">检测结果:</p>
|
||||
{checkResult.results.map((r) => (
|
||||
<div
|
||||
key={r.model}
|
||||
className={`flex items-center justify-between rounded p-2 text-sm ${
|
||||
r.available
|
||||
? "bg-green-50 dark:bg-green-900/20"
|
||||
: "bg-red-50 dark:bg-red-900/20"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.available ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
r.model.includes("tool_call")
|
||||
? "font-medium text-purple-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{r.model}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.status > 0 && <span>HTTP {r.status}</span>}
|
||||
<span>{r.time_ms}ms</span>
|
||||
{r.error_type && (
|
||||
<span className="rounded bg-red-100 dark:bg-red-900/50 px-1 text-red-600 dark:text-red-400">
|
||||
{r.error_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{checkResult.warnings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-600">警告:</p>
|
||||
{checkResult.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded bg-yellow-50 dark:bg-yellow-900/20 p-2 text-sm text-yellow-700 dark:text-yellow-400"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{w}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockGetConfig, mockSaveConfig } = vi.hoisted(() => ({
|
||||
mockGetConfig: vi.fn(),
|
||||
mockSaveConfig: vi.fn(),
|
||||
}));
|
||||
const { mockOpen } = vi.hoisted(() => ({
|
||||
mockOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useTauri", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-shell", () => ({
|
||||
open: mockOpen,
|
||||
}));
|
||||
|
||||
import { WebSearchSettings } from ".";
|
||||
|
||||
interface Mounted {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
|
||||
function renderComponent(): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<WebSearchSettings />);
|
||||
});
|
||||
mounted.push({ container, root });
|
||||
return container;
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const target = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes(text),
|
||||
);
|
||||
if (!target) {
|
||||
throw new Error(`未找到按钮: ${text}`);
|
||||
}
|
||||
return target as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function findSelect(container: HTMLElement, id: string): HTMLSelectElement {
|
||||
const node = container.querySelector<HTMLSelectElement>(`#${id}`);
|
||||
if (!node) {
|
||||
throw new Error(`未找到下拉框: ${id}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function findInput(container: HTMLElement, id: string): HTMLInputElement {
|
||||
const node = container.querySelector<HTMLInputElement>(`#${id}`);
|
||||
if (!node) {
|
||||
throw new Error(`未找到输入框: ${id}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
if (!nativeSetter) {
|
||||
throw new Error("未找到 input value setter");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
nativeSetter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
}
|
||||
|
||||
async function setSelectValue(select: HTMLSelectElement, value: string) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLSelectElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
if (!nativeSetter) {
|
||||
throw new Error("未找到 select value setter");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
nativeSetter.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetConfig.mockResolvedValue({
|
||||
web_search: {
|
||||
engine: "google",
|
||||
},
|
||||
image_gen: {
|
||||
image_search_pexels_api_key: "old-key",
|
||||
image_search_pixabay_api_key: "old-pixabay-key",
|
||||
},
|
||||
});
|
||||
mockSaveConfig.mockResolvedValue(undefined);
|
||||
mockOpen.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
const target = mounted.pop();
|
||||
if (!target) break;
|
||||
act(() => {
|
||||
target.root.unmount();
|
||||
});
|
||||
target.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WebSearchSettings", () => {
|
||||
it("应加载网络搜索与图片搜索配置", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
const select = findSelect(container, "web-search-engine");
|
||||
expect(select.value).toBe("google");
|
||||
|
||||
const input = findInput(container, "web-search-pexels-key");
|
||||
expect(input.value).toBe("old-key");
|
||||
const pixabayInput = findInput(container, "web-search-pixabay-key");
|
||||
expect(pixabayInput.value).toBe("old-pixabay-key");
|
||||
});
|
||||
|
||||
it("修改搜索引擎和图片 Key 后应统一保存", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await setSelectValue(
|
||||
findSelect(container, "web-search-engine"),
|
||||
"xiaohongshu",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-pexels-key"),
|
||||
"new-key",
|
||||
);
|
||||
await setInputValue(
|
||||
findInput(container, "web-search-pixabay-key"),
|
||||
"new-pixabay-key",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "保存").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockSaveConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
web_search: {
|
||||
engine: "xiaohongshu",
|
||||
},
|
||||
image_gen: expect.objectContaining({
|
||||
image_search_pexels_api_key: "new-key",
|
||||
image_search_pixabay_api_key: "new-pixabay-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("网络搜索设置已保存");
|
||||
});
|
||||
|
||||
it("点击一键申请 Key 应打开官方申请页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Pexels Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://www.pexels.com/api/new/");
|
||||
});
|
||||
|
||||
it("插件打开失败时应回退到 window.open", async () => {
|
||||
mockOpen.mockRejectedValueOnce(new Error("plugin failed"));
|
||||
const fallbackSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Pexels Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(fallbackSpy).toHaveBeenCalledWith(
|
||||
"https://www.pexels.com/api/new/",
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击 Pixabay 申请按钮应打开官方页面", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, "申请 Pixabay Key").click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://pixabay.com/accounts/register/");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Globe, RefreshCw } from "lucide-react";
|
||||
import { Globe, Image as ImageIcon, RefreshCw } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { getConfig, saveConfig, type Config } from "@/hooks/useTauri";
|
||||
|
||||
type SearchEngine = "google" | "xiaohongshu";
|
||||
const PEXELS_APPLY_URL = "https://www.pexels.com/api/new/";
|
||||
const PEXELS_DOC_URL = "https://www.pexels.com/api/";
|
||||
const PIXABAY_APPLY_URL = "https://pixabay.com/accounts/register/";
|
||||
const PIXABAY_DOC_URL = "https://pixabay.com/api/docs/";
|
||||
|
||||
export function WebSearchSettings() {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [draftEngine, setDraftEngine] = useState<SearchEngine>("google");
|
||||
const [draftPexelsApiKey, setDraftPexelsApiKey] = useState("");
|
||||
const [draftPixabayApiKey, setDraftPixabayApiKey] = useState("");
|
||||
const [showPexelsApiKey, setShowPexelsApiKey] = useState(false);
|
||||
const [showPixabayApiKey, setShowPixabayApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
@@ -19,9 +28,16 @@ export function WebSearchSettings() {
|
||||
setMessage(null);
|
||||
try {
|
||||
const nextConfig = await getConfig();
|
||||
const engine = (nextConfig.web_search?.engine || "google") as SearchEngine;
|
||||
const engine = (nextConfig.web_search?.engine ||
|
||||
"google") as SearchEngine;
|
||||
const pexelsApiKey =
|
||||
nextConfig.image_gen?.image_search_pexels_api_key || "";
|
||||
const pixabayApiKey =
|
||||
nextConfig.image_gen?.image_search_pixabay_api_key || "";
|
||||
setConfig(nextConfig);
|
||||
setDraftEngine(engine);
|
||||
setDraftPexelsApiKey(pexelsApiKey);
|
||||
setDraftPixabayApiKey(pixabayApiKey);
|
||||
} catch (error) {
|
||||
console.error("加载网络搜索配置失败:", error);
|
||||
setMessage({
|
||||
@@ -38,11 +54,24 @@ export function WebSearchSettings() {
|
||||
}, []);
|
||||
|
||||
const currentEngine = useMemo(
|
||||
() => ((config?.web_search?.engine || "google") as SearchEngine),
|
||||
() => (config?.web_search?.engine || "google") as SearchEngine,
|
||||
[config],
|
||||
);
|
||||
const currentPexelsApiKey = useMemo(
|
||||
() => config?.image_gen?.image_search_pexels_api_key || "",
|
||||
[config],
|
||||
);
|
||||
const currentPixabayApiKey = useMemo(
|
||||
() => config?.image_gen?.image_search_pixabay_api_key || "",
|
||||
[config],
|
||||
);
|
||||
|
||||
const hasUnsavedChanges = draftEngine !== currentEngine;
|
||||
const hasUnsavedChanges =
|
||||
draftEngine !== currentEngine ||
|
||||
draftPexelsApiKey.trim() !== currentPexelsApiKey ||
|
||||
draftPixabayApiKey.trim() !== currentPixabayApiKey;
|
||||
const pexelsKeyConfigured = draftPexelsApiKey.trim().length > 0;
|
||||
const pixabayKeyConfigured = draftPixabayApiKey.trim().length > 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config || !hasUnsavedChanges) return;
|
||||
@@ -54,6 +83,11 @@ export function WebSearchSettings() {
|
||||
web_search: {
|
||||
engine: draftEngine,
|
||||
},
|
||||
image_gen: {
|
||||
...(config.image_gen || {}),
|
||||
image_search_pexels_api_key: draftPexelsApiKey.trim(),
|
||||
image_search_pixabay_api_key: draftPixabayApiKey.trim(),
|
||||
},
|
||||
};
|
||||
await saveConfig(nextConfig);
|
||||
setConfig(nextConfig);
|
||||
@@ -71,9 +105,20 @@ export function WebSearchSettings() {
|
||||
|
||||
const handleReset = () => {
|
||||
setDraftEngine(currentEngine);
|
||||
setDraftPexelsApiKey(currentPexelsApiKey);
|
||||
setDraftPixabayApiKey(currentPixabayApiKey);
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
try {
|
||||
await open(url);
|
||||
} catch (error) {
|
||||
console.error("打开外部链接失败:", error);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
@@ -126,6 +171,139 @@ export function WebSearchSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">联网图片搜索</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
配置插图页「图片搜索 → 联网搜索」使用的 Pexels API Key。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
pexelsKeyConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Pexels {pexelsKeyConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs ${
|
||||
pixabayKeyConfigured
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Pixabay {pixabayKeyConfigured ? "已填写" : "未填写"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label
|
||||
htmlFor="web-search-pexels-key"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Pexels API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(PEXELS_APPLY_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
申请 Pexels Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(PEXELS_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="web-search-pexels-key"
|
||||
type={showPexelsApiKey ? "text" : "password"}
|
||||
value={draftPexelsApiKey}
|
||||
onChange={(e) => setDraftPexelsApiKey(e.target.value)}
|
||||
placeholder="输入 Pexels API Key"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 pr-20 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPexelsApiKey((prev) => !prev)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{showPexelsApiKey ? "隐藏" : "显示"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
未填写时会回退读取环境变量 <code>PEXELS_API_KEY</code>。
|
||||
</p>
|
||||
<p>申请地址:{PEXELS_APPLY_URL}</p>
|
||||
<p>验证路径:插图 → 图片搜索 → 联网搜索。</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<label
|
||||
htmlFor="web-search-pixabay-key"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Pixabay API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(PIXABAY_APPLY_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
申请 Pixabay Key
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openExternalUrl(PIXABAY_DOC_URL)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs hover:bg-muted"
|
||||
>
|
||||
查看文档
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="web-search-pixabay-key"
|
||||
type={showPixabayApiKey ? "text" : "password"}
|
||||
value={draftPixabayApiKey}
|
||||
onChange={(e) => setDraftPixabayApiKey(e.target.value)}
|
||||
placeholder="输入 Pixabay API Key"
|
||||
className="w-full h-10 rounded-md border bg-background px-3 pr-20 text-sm outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPixabayApiKey((prev) => !prev)}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md border px-2.5 py-1 text-xs"
|
||||
>
|
||||
{showPixabayApiKey ? "隐藏" : "显示"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
未填写时会回退读取环境变量 <code>PIXABAY_API_KEY</code>。
|
||||
</p>
|
||||
<p>申请地址:{PIXABAY_APPLY_URL}</p>
|
||||
<p>验证路径:插图 → 图片搜索 → Pixabay图库。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border rounded-lg px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasUnsavedChanges ? "未保存的更改" : "所有更改已保存"}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { WorkspaceShell, WorkspaceTopbar } from "@/components/workspace/shell";
|
||||
import {
|
||||
WorkbenchCreateContentDialog,
|
||||
WorkbenchCreateContentDialogBoundary,
|
||||
WorkbenchCreateProjectDialog,
|
||||
} from "@/components/workspace/dialogs";
|
||||
import {
|
||||
@@ -255,42 +256,48 @@ export function WorkbenchPage({
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkbenchCreateContentDialog
|
||||
<WorkbenchCreateContentDialogBoundary
|
||||
open={createContentDialogOpen}
|
||||
creatingContent={creatingContent}
|
||||
step={createContentDialogStep}
|
||||
selectedProjectId={selectedProjectId}
|
||||
creationModeOptions={CREATION_MODE_OPTIONS}
|
||||
selectedCreationMode={selectedCreationMode}
|
||||
onCreationModeChange={setSelectedCreationMode}
|
||||
currentCreationIntentFields={currentCreationIntentFields}
|
||||
creationIntentValues={creationIntentValues}
|
||||
onCreationIntentValueChange={handleCreationIntentValueChange}
|
||||
currentIntentLength={currentIntentLength}
|
||||
minCreationIntentLength={MIN_CREATION_INTENT_LENGTH}
|
||||
creationIntentError={creationIntentError}
|
||||
onOpenChange={(open) => {
|
||||
if (!creatingContent) {
|
||||
setCreateContentDialogOpen(open);
|
||||
if (!open) {
|
||||
resetCreateContentDialogState();
|
||||
mode={selectedCreationMode}
|
||||
>
|
||||
<WorkbenchCreateContentDialog
|
||||
open={createContentDialogOpen}
|
||||
creatingContent={creatingContent}
|
||||
step={createContentDialogStep}
|
||||
selectedProjectId={selectedProjectId}
|
||||
creationModeOptions={CREATION_MODE_OPTIONS}
|
||||
selectedCreationMode={selectedCreationMode}
|
||||
onCreationModeChange={setSelectedCreationMode}
|
||||
currentCreationIntentFields={currentCreationIntentFields}
|
||||
creationIntentValues={creationIntentValues}
|
||||
onCreationIntentValueChange={handleCreationIntentValueChange}
|
||||
currentIntentLength={currentIntentLength}
|
||||
minCreationIntentLength={MIN_CREATION_INTENT_LENGTH}
|
||||
creationIntentError={creationIntentError}
|
||||
onOpenChange={(open) => {
|
||||
if (!creatingContent) {
|
||||
setCreateContentDialogOpen(open);
|
||||
if (!open) {
|
||||
resetCreateContentDialogState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBackOrCancel={() => {
|
||||
if (createContentDialogStep === "intent") {
|
||||
setCreateContentDialogStep("mode");
|
||||
setCreationIntentError("");
|
||||
return;
|
||||
}
|
||||
setCreateContentDialogOpen(false);
|
||||
resetCreateContentDialogState();
|
||||
}}
|
||||
onGoToIntentStep={handleGoToIntentStep}
|
||||
onCreateContent={() => {
|
||||
void handleCreateContent();
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
onBackOrCancel={() => {
|
||||
if (createContentDialogStep === "intent") {
|
||||
setCreateContentDialogStep("mode");
|
||||
setCreationIntentError("");
|
||||
return;
|
||||
}
|
||||
setCreateContentDialogOpen(false);
|
||||
resetCreateContentDialogState();
|
||||
}}
|
||||
onGoToIntentStep={handleGoToIntentStep}
|
||||
onCreateContent={() => {
|
||||
void handleCreateContent();
|
||||
}}
|
||||
/>
|
||||
</WorkbenchCreateContentDialogBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,4 +153,17 @@ describe("WorkbenchCreateContentDialog", () => {
|
||||
expect(onBackOrCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onCreateContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("意图字段配置异常时应回退到最小可用输入", () => {
|
||||
renderDialog({
|
||||
step: "intent",
|
||||
currentCreationIntentFields: [] as ContentDialogProps["currentCreationIntentFields"],
|
||||
});
|
||||
|
||||
const fallbackTopicInput = findInputById(
|
||||
document.body,
|
||||
"creation-intent-topic",
|
||||
) as HTMLInputElement | null;
|
||||
expect(fallbackTopicInput).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CreationMode } from "@/components/content-creator/types";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +20,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
clearCrashContext,
|
||||
reportFrontendError,
|
||||
updateCrashContext,
|
||||
} from "@/lib/crashReporting";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreationIntentFieldDefinition,
|
||||
CreationIntentFieldKey,
|
||||
@@ -51,6 +58,55 @@ export interface WorkbenchCreateContentDialogProps {
|
||||
onCreateContent: () => void;
|
||||
}
|
||||
|
||||
const FALLBACK_CREATION_INTENT_FIELD: CreationIntentFieldDefinition = {
|
||||
key: "topic",
|
||||
label: "创作主题",
|
||||
placeholder: "请输入创作主题",
|
||||
};
|
||||
|
||||
function getSafeIntentFields(
|
||||
fields: CreationIntentFieldDefinition[] | null | undefined,
|
||||
): CreationIntentFieldDefinition[] {
|
||||
if (!Array.isArray(fields)) {
|
||||
return [FALLBACK_CREATION_INTENT_FIELD];
|
||||
}
|
||||
|
||||
const sanitized = fields
|
||||
.filter(
|
||||
(field): field is CreationIntentFieldDefinition =>
|
||||
Boolean(field) &&
|
||||
typeof field.key === "string" &&
|
||||
typeof field.label === "string" &&
|
||||
typeof field.placeholder === "string",
|
||||
)
|
||||
.map((field) => ({
|
||||
...field,
|
||||
options:
|
||||
Array.isArray(field.options) && field.options.length > 0
|
||||
? field.options.filter(
|
||||
(option): option is { value: string; label: string } =>
|
||||
Boolean(option) &&
|
||||
typeof option.value === "string" &&
|
||||
typeof option.label === "string",
|
||||
)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return [FALLBACK_CREATION_INTENT_FIELD];
|
||||
}
|
||||
|
||||
function getFieldValue(
|
||||
values: CreationIntentFormValues,
|
||||
key: CreationIntentFieldKey,
|
||||
): string {
|
||||
const value = values[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function WorkbenchCreateContentDialog({
|
||||
open,
|
||||
creatingContent,
|
||||
@@ -70,6 +126,49 @@ export function WorkbenchCreateContentDialog({
|
||||
onGoToIntentStep,
|
||||
onCreateContent,
|
||||
}: WorkbenchCreateContentDialogProps) {
|
||||
const safeCreationIntentFields = useMemo(
|
||||
() => getSafeIntentFields(currentCreationIntentFields),
|
||||
[currentCreationIntentFields],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
clearCrashContext(["workflow_step", "creation_mode", "project_id"]);
|
||||
return;
|
||||
}
|
||||
|
||||
updateCrashContext({
|
||||
workflow_step: `workspace_creation_${step}`,
|
||||
creation_mode: selectedCreationMode,
|
||||
project_id: selectedProjectId,
|
||||
});
|
||||
}, [open, selectedCreationMode, selectedProjectId, step]);
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
try {
|
||||
if (step === "mode") {
|
||||
onGoToIntentStep();
|
||||
return;
|
||||
}
|
||||
onCreateContent();
|
||||
} catch (error) {
|
||||
console.error("[WorkbenchCreateContentDialog] 主操作异常:", error);
|
||||
void reportFrontendError(error, {
|
||||
component: "WorkbenchCreateContentDialog",
|
||||
workflow_step: `workspace_creation_${step}`,
|
||||
creation_mode: selectedCreationMode,
|
||||
project_id: selectedProjectId,
|
||||
});
|
||||
toast.error("操作失败,请重试");
|
||||
}
|
||||
}, [
|
||||
onCreateContent,
|
||||
onGoToIntentStep,
|
||||
selectedCreationMode,
|
||||
selectedProjectId,
|
||||
step,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
@@ -119,12 +218,12 @@ export function WorkbenchCreateContentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{currentCreationIntentFields.map((field) => (
|
||||
{safeCreationIntentFields.map((field) => (
|
||||
<div key={field.key} className="grid gap-2">
|
||||
<Label>{field.label}</Label>
|
||||
{field.options && field.options.length > 0 ? (
|
||||
<Select
|
||||
value={creationIntentValues[field.key] || undefined}
|
||||
value={getFieldValue(creationIntentValues, field.key) || undefined}
|
||||
onValueChange={(value) =>
|
||||
onCreationIntentValueChange(field.key, value)
|
||||
}
|
||||
@@ -144,7 +243,7 @@ export function WorkbenchCreateContentDialog({
|
||||
) : field.multiline ? (
|
||||
<Textarea
|
||||
id={`creation-intent-${field.key}`}
|
||||
value={creationIntentValues[field.key]}
|
||||
value={getFieldValue(creationIntentValues, field.key)}
|
||||
onChange={(event) =>
|
||||
onCreationIntentValueChange(field.key, event.target.value)
|
||||
}
|
||||
@@ -155,7 +254,7 @@ export function WorkbenchCreateContentDialog({
|
||||
) : (
|
||||
<Input
|
||||
id={`creation-intent-${field.key}`}
|
||||
value={creationIntentValues[field.key]}
|
||||
value={getFieldValue(creationIntentValues, field.key)}
|
||||
onChange={(event) =>
|
||||
onCreationIntentValueChange(field.key, event.target.value)
|
||||
}
|
||||
@@ -204,13 +303,7 @@ export function WorkbenchCreateContentDialog({
|
||||
{step === "mode" ? "取消" : "上一步"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (step === "mode") {
|
||||
onGoToIntentStep();
|
||||
return;
|
||||
}
|
||||
onCreateContent();
|
||||
}}
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={
|
||||
!selectedProjectId ||
|
||||
creatingContent ||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { type ErrorInfo } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reportFrontendError } from "@/lib/crashReporting";
|
||||
|
||||
interface WorkbenchCreateContentDialogBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
step: "mode" | "intent";
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface WorkbenchCreateContentDialogBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class WorkbenchCreateContentDialogBoundary extends React.Component<
|
||||
WorkbenchCreateContentDialogBoundaryProps,
|
||||
WorkbenchCreateContentDialogBoundaryState
|
||||
> {
|
||||
state: WorkbenchCreateContentDialogBoundaryState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
const context = {
|
||||
step: this.props.step,
|
||||
mode: this.props.mode,
|
||||
componentStack: errorInfo.componentStack,
|
||||
};
|
||||
console.error("[WorkbenchCreateContentDialogBoundary] 捕获异常:", error, {
|
||||
...context,
|
||||
});
|
||||
void reportFrontendError(error, {
|
||||
component: "WorkbenchCreateContentDialogBoundary",
|
||||
workflow_step: `workspace_creation_${this.props.step}`,
|
||||
creation_mode: this.props.mode,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: WorkbenchCreateContentDialogBoundaryProps,
|
||||
): void {
|
||||
if (
|
||||
this.state.error &&
|
||||
(!this.props.open ||
|
||||
prevProps.step !== this.props.step ||
|
||||
prevProps.mode !== this.props.mode)
|
||||
) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, open, mode, step } = this.props;
|
||||
const { error } = this.state;
|
||||
|
||||
if (!error) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-sm rounded-lg border bg-background p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">创作表单加载失败</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
请切换模式后重试。当前模式:{mode},步骤:{step}
|
||||
</p>
|
||||
<Button size="sm" onClick={this.handleRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkbenchCreateContentDialogBoundary;
|
||||
@@ -5,3 +5,4 @@ export type {
|
||||
WorkbenchCreateContentDialogProps,
|
||||
WorkbenchCreateContentDialogStep,
|
||||
} from "./WorkbenchCreateContentDialog";
|
||||
export { WorkbenchCreateContentDialogBoundary } from "./WorkbenchCreateContentDialogBoundary";
|
||||
|
||||
@@ -74,6 +74,7 @@ function CreationDialogsHarness(props: HarnessProps) {
|
||||
data-create-project-open={String(dialogs.createProjectDialogOpen)}
|
||||
data-create-content-open={String(dialogs.createContentDialogOpen)}
|
||||
data-create-content-step={dialogs.createContentDialogStep}
|
||||
data-selected-creation-mode={dialogs.selectedCreationMode}
|
||||
data-new-project-name={dialogs.newProjectName}
|
||||
data-workspace-root={dialogs.workspaceProjectsRoot}
|
||||
data-resolved-project-path={dialogs.resolvedProjectPath}
|
||||
@@ -114,6 +115,18 @@ function CreationDialogsHarness(props: HarnessProps) {
|
||||
onClick={dialogs.handleOpenCreateContentDialog}
|
||||
/>
|
||||
<button data-testid="goto-intent" onClick={dialogs.handleGoToIntentStep} />
|
||||
<button
|
||||
data-testid="set-mode-hybrid"
|
||||
onClick={() => dialogs.setSelectedCreationMode("hybrid")}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-mode-invalid"
|
||||
onClick={() =>
|
||||
(dialogs.setSelectedCreationMode as (mode: unknown) => void)(
|
||||
"ai-discuss",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
data-testid="fill-intent-topic"
|
||||
onClick={() =>
|
||||
@@ -330,6 +343,39 @@ describe("useCreationDialogs", () => {
|
||||
expect(mockCreateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("切换 hybrid 模式后应稳定进入意图步骤", async () => {
|
||||
const { container } = renderHarness({
|
||||
selectedProjectId: "project-1",
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
click(container, "open-content-dialog");
|
||||
await flushEffects();
|
||||
click(container, "set-mode-hybrid");
|
||||
await flushEffects();
|
||||
click(container, "goto-intent");
|
||||
await flushEffects();
|
||||
|
||||
const root = getRootElement(container);
|
||||
expect(root?.dataset.selectedCreationMode).toBe("hybrid");
|
||||
expect(root?.dataset.createContentStep).toBe("intent");
|
||||
});
|
||||
|
||||
it("模式值异常时应自动回退到 guided", async () => {
|
||||
const { container } = renderHarness({
|
||||
selectedProjectId: "project-1",
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
click(container, "open-content-dialog");
|
||||
await flushEffects();
|
||||
click(container, "set-mode-invalid");
|
||||
await flushEffects();
|
||||
|
||||
const root = getRootElement(container);
|
||||
expect(root?.dataset.selectedCreationMode).toBe("guided");
|
||||
});
|
||||
|
||||
it("创作意图通过后创建文稿应写入待发送提示并进入工作区", async () => {
|
||||
const loadContents = vi.fn(async () => undefined);
|
||||
const onEnterWorkspace = vi.fn();
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
@@ -27,32 +28,110 @@ import {
|
||||
buildCreationIntentMetadata,
|
||||
buildCreationIntentPrompt,
|
||||
createInitialCreationIntentValues,
|
||||
getCreationIntentFields,
|
||||
getCreationIntentFieldsSafe,
|
||||
isCreationMode,
|
||||
normalizeCreationMode,
|
||||
type CreationIntentFieldKey,
|
||||
type CreationIntentFormValues,
|
||||
type CreationIntentInput,
|
||||
validateCreationIntent,
|
||||
} from "@/components/workspace/utils/creationIntentPrompt";
|
||||
import { reportFrontendError } from "@/lib/crashReporting";
|
||||
|
||||
type CreateContentDialogStep = "mode" | "intent";
|
||||
|
||||
function parseCreationMode(value: unknown): CreationMode | null {
|
||||
if (
|
||||
value === "guided" ||
|
||||
value === "fast" ||
|
||||
value === "hybrid" ||
|
||||
value === "framework"
|
||||
) {
|
||||
return value;
|
||||
interface CreateContentDialogState {
|
||||
step: CreateContentDialogStep;
|
||||
selectedCreationMode: CreationMode;
|
||||
creationIntentValues: CreationIntentFormValues;
|
||||
creationIntentError: string;
|
||||
}
|
||||
|
||||
type CreateContentDialogAction =
|
||||
| {
|
||||
type: "reset";
|
||||
defaultMode: CreationMode;
|
||||
}
|
||||
| {
|
||||
type: "setStep";
|
||||
step: CreateContentDialogStep;
|
||||
}
|
||||
| {
|
||||
type: "setMode";
|
||||
mode: unknown;
|
||||
}
|
||||
| {
|
||||
type: "setError";
|
||||
error: string;
|
||||
}
|
||||
| {
|
||||
type: "updateIntentValue";
|
||||
key: CreationIntentFieldKey;
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: "goIntentStep";
|
||||
};
|
||||
|
||||
function createInitialContentDialogState(
|
||||
defaultMode: CreationMode,
|
||||
): CreateContentDialogState {
|
||||
return {
|
||||
step: "mode",
|
||||
selectedCreationMode: normalizeCreationMode(defaultMode),
|
||||
creationIntentValues: createInitialCreationIntentValues(),
|
||||
creationIntentError: "",
|
||||
};
|
||||
}
|
||||
|
||||
function createContentDialogReducer(
|
||||
state: CreateContentDialogState,
|
||||
action: CreateContentDialogAction,
|
||||
): CreateContentDialogState {
|
||||
switch (action.type) {
|
||||
case "reset":
|
||||
return createInitialContentDialogState(action.defaultMode);
|
||||
case "setStep":
|
||||
return {
|
||||
...state,
|
||||
step: action.step,
|
||||
};
|
||||
case "setMode":
|
||||
return {
|
||||
...state,
|
||||
selectedCreationMode: normalizeCreationMode(action.mode),
|
||||
};
|
||||
case "setError":
|
||||
return {
|
||||
...state,
|
||||
creationIntentError: action.error,
|
||||
};
|
||||
case "updateIntentValue":
|
||||
return {
|
||||
...state,
|
||||
creationIntentValues: {
|
||||
...state.creationIntentValues,
|
||||
[action.key]: action.value,
|
||||
},
|
||||
creationIntentError: "",
|
||||
};
|
||||
case "goIntentStep":
|
||||
return {
|
||||
...state,
|
||||
step: "intent",
|
||||
creationIntentError: "",
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCreationModeFromMetadata(metadata: unknown): CreationMode | null {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parseCreationMode((metadata as Record<string, unknown>).creationMode);
|
||||
const mode = (metadata as Record<string, unknown>).creationMode;
|
||||
return isCreationMode(mode) ? mode : null;
|
||||
}
|
||||
|
||||
function useWorkspaceProjectsRootLoader(
|
||||
@@ -263,17 +342,15 @@ export function useCreationDialogs({
|
||||
}: UseCreationDialogsParams) {
|
||||
const [createProjectDialogOpen, setCreateProjectDialogOpen] = useState(false);
|
||||
const [createContentDialogOpen, setCreateContentDialogOpen] = useState(false);
|
||||
const [createContentDialogStep, setCreateContentDialogStep] =
|
||||
useState<CreateContentDialogStep>("mode");
|
||||
const [createContentDialogState, dispatchCreateContentDialog] = useReducer(
|
||||
createContentDialogReducer,
|
||||
defaultCreationMode,
|
||||
createInitialContentDialogState,
|
||||
);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [workspaceProjectsRoot, setWorkspaceProjectsRoot] = useState("");
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [creatingContent, setCreatingContent] = useState(false);
|
||||
const [selectedCreationMode, setSelectedCreationMode] =
|
||||
useState<CreationMode>(defaultCreationMode);
|
||||
const [creationIntentValues, setCreationIntentValues] =
|
||||
useState<CreationIntentFormValues>(() => createInitialCreationIntentValues());
|
||||
const [creationIntentError, setCreationIntentError] = useState("");
|
||||
const [resolvedProjectPath, setResolvedProjectPath] = useState("");
|
||||
const [pathChecking, setPathChecking] = useState(false);
|
||||
const [pathConflictMessage, setPathConflictMessage] = useState("");
|
||||
@@ -291,15 +368,15 @@ export function useCreationDialogs({
|
||||
|
||||
const creationIntentInput = useMemo<CreationIntentInput>(
|
||||
() => ({
|
||||
creationMode: selectedCreationMode,
|
||||
values: creationIntentValues,
|
||||
creationMode: createContentDialogState.selectedCreationMode,
|
||||
values: createContentDialogState.creationIntentValues,
|
||||
}),
|
||||
[selectedCreationMode, creationIntentValues],
|
||||
[createContentDialogState.creationIntentValues, createContentDialogState.selectedCreationMode],
|
||||
);
|
||||
|
||||
const currentCreationIntentFields = useMemo(
|
||||
() => getCreationIntentFields(selectedCreationMode),
|
||||
[selectedCreationMode],
|
||||
() => getCreationIntentFieldsSafe(createContentDialogState.selectedCreationMode),
|
||||
[createContentDialogState.selectedCreationMode],
|
||||
);
|
||||
|
||||
const currentIntentLength = useMemo(
|
||||
@@ -309,12 +386,36 @@ export function useCreationDialogs({
|
||||
);
|
||||
|
||||
const resetCreateContentDialogState = useCallback(() => {
|
||||
setCreateContentDialogStep("mode");
|
||||
setSelectedCreationMode(defaultCreationMode);
|
||||
setCreationIntentValues(createInitialCreationIntentValues());
|
||||
setCreationIntentError("");
|
||||
dispatchCreateContentDialog({
|
||||
type: "reset",
|
||||
defaultMode: defaultCreationMode,
|
||||
});
|
||||
}, [defaultCreationMode]);
|
||||
|
||||
const setCreateContentDialogStep = useCallback(
|
||||
(step: CreateContentDialogStep) => {
|
||||
dispatchCreateContentDialog({
|
||||
type: "setStep",
|
||||
step,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setSelectedCreationMode = useCallback((mode: CreationMode) => {
|
||||
dispatchCreateContentDialog({
|
||||
type: "setMode",
|
||||
mode,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setCreationIntentError = useCallback((error: string) => {
|
||||
dispatchCreateContentDialog({
|
||||
type: "setError",
|
||||
error,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenCreateProjectDialog = useCallback(() => {
|
||||
setNewProjectName(`${getProjectTypeLabel(theme as ProjectType)}项目`);
|
||||
resetProjectPathState();
|
||||
@@ -343,6 +444,10 @@ export function useCreationDialogs({
|
||||
await loadProjects();
|
||||
} catch (error) {
|
||||
console.error("创建项目失败:", error);
|
||||
void reportFrontendError(error, {
|
||||
component: "useCreationDialogs",
|
||||
workflow_step: "workspace_creation_create_project",
|
||||
});
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
|
||||
toast.error(`创建项目失败: ${friendlyMessage}`);
|
||||
@@ -361,21 +466,42 @@ export function useCreationDialogs({
|
||||
|
||||
const handleCreationIntentValueChange = useCallback(
|
||||
(key: CreationIntentFieldKey, value: string) => {
|
||||
setCreationIntentValues((previous) => ({
|
||||
...previous,
|
||||
[key]: value,
|
||||
}));
|
||||
if (creationIntentError) {
|
||||
setCreationIntentError("");
|
||||
}
|
||||
dispatchCreateContentDialog({
|
||||
type: "updateIntentValue",
|
||||
key,
|
||||
value,
|
||||
});
|
||||
},
|
||||
[creationIntentError],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGoToIntentStep = useCallback(() => {
|
||||
setCreateContentDialogStep("intent");
|
||||
setCreationIntentError("");
|
||||
}, []);
|
||||
const fields = getCreationIntentFieldsSafe(
|
||||
createContentDialogState.selectedCreationMode,
|
||||
);
|
||||
const hasOnlyFallbackTopicField =
|
||||
fields.length === 1 && fields[0]?.key === "topic";
|
||||
if (
|
||||
hasOnlyFallbackTopicField &&
|
||||
createContentDialogState.selectedCreationMode !== "guided"
|
||||
) {
|
||||
console.warn(
|
||||
"[useCreationDialogs] 检测到创作模式字段异常,已降级为引导模式",
|
||||
{
|
||||
mode: createContentDialogState.selectedCreationMode,
|
||||
fieldCount: fields.length,
|
||||
},
|
||||
);
|
||||
dispatchCreateContentDialog({
|
||||
type: "setMode",
|
||||
mode: "guided",
|
||||
});
|
||||
}
|
||||
|
||||
dispatchCreateContentDialog({
|
||||
type: "goIntentStep",
|
||||
});
|
||||
}, [createContentDialogState.selectedCreationMode]);
|
||||
|
||||
const handleCreateContent = useCallback(async () => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -403,14 +529,14 @@ export function useCreationDialogs({
|
||||
title: `新${getContentTypeLabel(defaultType)}`,
|
||||
content_type: defaultType,
|
||||
metadata: {
|
||||
creationMode: selectedCreationMode,
|
||||
creationMode: createContentDialogState.selectedCreationMode,
|
||||
creationIntent: creationIntentMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
setContentCreationModes((previous) => ({
|
||||
...previous,
|
||||
[created.id]: selectedCreationMode,
|
||||
[created.id]: createContentDialogState.selectedCreationMode,
|
||||
}));
|
||||
setPendingInitialPromptsByContentId((previous) => ({
|
||||
...previous,
|
||||
@@ -423,6 +549,11 @@ export function useCreationDialogs({
|
||||
toast.success("已创建新文稿");
|
||||
} catch (error) {
|
||||
console.error("创建文稿失败:", error);
|
||||
void reportFrontendError(error, {
|
||||
component: "useCreationDialogs",
|
||||
workflow_step: "workspace_creation_submit_intent",
|
||||
creation_mode: createContentDialogState.selectedCreationMode,
|
||||
});
|
||||
toast.error("创建文稿失败");
|
||||
} finally {
|
||||
setCreatingContent(false);
|
||||
@@ -433,8 +564,9 @@ export function useCreationDialogs({
|
||||
minCreationIntentLength,
|
||||
onEnterWorkspace,
|
||||
resetCreateContentDialogState,
|
||||
selectedCreationMode,
|
||||
createContentDialogState.selectedCreationMode,
|
||||
selectedProjectId,
|
||||
setCreationIntentError,
|
||||
theme,
|
||||
]);
|
||||
|
||||
@@ -473,17 +605,17 @@ export function useCreationDialogs({
|
||||
setCreateProjectDialogOpen,
|
||||
createContentDialogOpen,
|
||||
setCreateContentDialogOpen,
|
||||
createContentDialogStep,
|
||||
createContentDialogStep: createContentDialogState.step,
|
||||
setCreateContentDialogStep,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
workspaceProjectsRoot,
|
||||
creatingProject,
|
||||
creatingContent,
|
||||
selectedCreationMode,
|
||||
selectedCreationMode: createContentDialogState.selectedCreationMode,
|
||||
setSelectedCreationMode,
|
||||
creationIntentValues,
|
||||
creationIntentError,
|
||||
creationIntentValues: createContentDialogState.creationIntentValues,
|
||||
creationIntentError: createContentDialogState.creationIntentError,
|
||||
setCreationIntentError,
|
||||
currentCreationIntentFields,
|
||||
currentIntentLength,
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
buildCreationIntentPrompt,
|
||||
createInitialCreationIntentValues,
|
||||
getCreationIntentFields,
|
||||
getCreationIntentFieldsSafe,
|
||||
getCreationIntentText,
|
||||
normalizeCreationMode,
|
||||
validateCreationIntent,
|
||||
} from "./creationIntentPrompt";
|
||||
|
||||
@@ -28,6 +30,15 @@ describe("creationIntentPrompt", () => {
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
it("应对非法模式做安全归一化", () => {
|
||||
expect(normalizeCreationMode("hybrid")).toBe("hybrid");
|
||||
expect(normalizeCreationMode("ai-discuss")).toBe("guided");
|
||||
|
||||
const safeFields = getCreationIntentFieldsSafe("ai-discuss");
|
||||
expect(safeFields).toHaveLength(4);
|
||||
expect(safeFields[0]?.key).toBe("topic");
|
||||
});
|
||||
|
||||
it("应正确校验最小意图长度", () => {
|
||||
const input = {
|
||||
creationMode: "fast" as const,
|
||||
|
||||
@@ -49,6 +49,27 @@ export interface CreationIntentValidationResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const CREATION_MODE_VALUES: CreationMode[] = [
|
||||
"guided",
|
||||
"fast",
|
||||
"hybrid",
|
||||
"framework",
|
||||
];
|
||||
|
||||
const CREATION_INTENT_FIELD_KEYS: CreationIntentFieldKey[] = [
|
||||
"topic",
|
||||
"targetAudience",
|
||||
"goal",
|
||||
"constraints",
|
||||
"contentType",
|
||||
"length",
|
||||
"corePoints",
|
||||
"tone",
|
||||
"outline",
|
||||
"mustInclude",
|
||||
"extraRequirements",
|
||||
];
|
||||
|
||||
const CREATION_MODE_LABELS: Record<CreationMode, string> = {
|
||||
guided: "引导模式",
|
||||
fast: "快速模式",
|
||||
@@ -194,6 +215,103 @@ const CREATION_INTENT_FIELDS_BY_MODE: Record<
|
||||
],
|
||||
};
|
||||
|
||||
const FALLBACK_CREATION_INTENT_FIELDS: CreationIntentFieldDefinition[] = [
|
||||
{
|
||||
...CREATION_INTENT_FIELD_MAP.topic,
|
||||
},
|
||||
];
|
||||
|
||||
export function isCreationMode(value: unknown): value is CreationMode {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
CREATION_MODE_VALUES.includes(value as CreationMode)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCreationMode(
|
||||
value: unknown,
|
||||
fallback: CreationMode = "guided",
|
||||
): CreationMode {
|
||||
return isCreationMode(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function isCreationIntentFieldKey(value: unknown): value is CreationIntentFieldKey {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
CREATION_INTENT_FIELD_KEYS.includes(value as CreationIntentFieldKey)
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFieldDefinition(
|
||||
field: unknown,
|
||||
): CreationIntentFieldDefinition | null {
|
||||
if (!field || typeof field !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldRecord = field as Partial<CreationIntentFieldDefinition>;
|
||||
if (!isCreationIntentFieldKey(fieldRecord.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof fieldRecord.label !== "string" || !fieldRecord.label.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedOptions =
|
||||
Array.isArray(fieldRecord.options) && fieldRecord.options.length > 0
|
||||
? fieldRecord.options
|
||||
.filter(
|
||||
(option): option is { value: string; label: string } =>
|
||||
Boolean(option) &&
|
||||
typeof option.value === "string" &&
|
||||
option.value.trim().length > 0 &&
|
||||
typeof option.label === "string" &&
|
||||
option.label.trim().length > 0,
|
||||
)
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
key: fieldRecord.key,
|
||||
label: fieldRecord.label,
|
||||
placeholder:
|
||||
typeof fieldRecord.placeholder === "string" &&
|
||||
fieldRecord.placeholder.trim().length > 0
|
||||
? fieldRecord.placeholder
|
||||
: "请输入内容",
|
||||
multiline: fieldRecord.multiline === true,
|
||||
options:
|
||||
normalizedOptions && normalizedOptions.length > 0
|
||||
? normalizedOptions
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCreationIntentFieldsSafe(
|
||||
mode: unknown,
|
||||
): CreationIntentFieldDefinition[] {
|
||||
const normalizedMode = normalizeCreationMode(mode);
|
||||
const fieldDefs = CREATION_INTENT_FIELDS_BY_MODE[normalizedMode] || [];
|
||||
const sanitized = fieldDefs
|
||||
.map((field) => sanitizeFieldDefinition(field))
|
||||
.filter((field): field is CreationIntentFieldDefinition => Boolean(field));
|
||||
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[creationIntentPrompt] 创作意图字段配置异常,已降级为最小字段集合",
|
||||
{ mode: normalizedMode },
|
||||
);
|
||||
|
||||
return FALLBACK_CREATION_INTENT_FIELDS.map((field) => ({ ...field }));
|
||||
}
|
||||
|
||||
function normalizeValue(value: string | undefined): string {
|
||||
return (value || "").trim();
|
||||
}
|
||||
@@ -201,7 +319,7 @@ function normalizeValue(value: string | undefined): string {
|
||||
function getRelevantFieldEntries(
|
||||
input: CreationIntentInput,
|
||||
): Array<readonly [CreationIntentFieldDefinition, string]> {
|
||||
const fieldDefs = CREATION_INTENT_FIELDS_BY_MODE[input.creationMode] || [];
|
||||
const fieldDefs = getCreationIntentFieldsSafe(input.creationMode);
|
||||
return fieldDefs
|
||||
.map((field) => [field, normalizeValue(input.values[field.key])] as const)
|
||||
.filter(([, value]) => value.length > 0);
|
||||
@@ -234,7 +352,7 @@ export function createInitialCreationIntentValues(): CreationIntentFormValues {
|
||||
export function getCreationIntentFields(
|
||||
mode: CreationMode,
|
||||
): CreationIntentFieldDefinition[] {
|
||||
return CREATION_INTENT_FIELDS_BY_MODE[mode] || [];
|
||||
return getCreationIntentFieldsSafe(mode);
|
||||
}
|
||||
|
||||
export function getCreationIntentText(input: CreationIntentInput): string {
|
||||
|
||||
@@ -292,6 +292,10 @@ export interface ImageGenConfig {
|
||||
enable_enhancement?: boolean;
|
||||
/** 自动下载生成的图像 */
|
||||
auto_download?: boolean;
|
||||
/** 图片搜索(Pexels)API Key */
|
||||
image_search_pexels_api_key?: string;
|
||||
/** 图片搜索(Pixabay)API Key */
|
||||
image_search_pixabay_api_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,6 +367,14 @@ export interface ChannelsConfig {
|
||||
feishu: FeishuBotConfig;
|
||||
}
|
||||
|
||||
export interface CrashReportingConfig {
|
||||
enabled: boolean;
|
||||
dsn?: string | null;
|
||||
environment?: string;
|
||||
sample_rate?: number;
|
||||
send_pii?: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: {
|
||||
host: string;
|
||||
@@ -429,6 +441,8 @@ export interface Config {
|
||||
user_profile?: UserProfile;
|
||||
/** 渠道配置(Telegram / Discord / 飞书 Bot) */
|
||||
channels?: ChannelsConfig;
|
||||
/** 崩溃上报配置(Sentry 协议兼容) */
|
||||
crash_reporting?: CrashReportingConfig;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
|
||||
@@ -3841,5 +3841,32 @@
|
||||
"Webhook URL 不能为空": "Webhook URL cannot be empty",
|
||||
"Bot Token 不能为空": "Bot token cannot be empty",
|
||||
"聊天 ID 不能为空": "Chat ID cannot be empty",
|
||||
"创作类 AI Agent 平台": "Creative AI Agent Platform",
|
||||
"把灵感、写作、出图、改稿、沉淀放进同一个工作台": "Bring ideation, writing, image generation, editing, and asset reuse into one workspace.",
|
||||
"版本 {{version}}": "Version {{version}}",
|
||||
"有新版本 {{version}}": "New version {{version}}",
|
||||
"产品定位": "Product Positioning",
|
||||
"ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。": "ProxyCast is an AI Agent platform for everyday creators. You do not need complex setup first—bring an idea and complete direction setting, content generation, iteration, and reusable asset accumulation in one place.",
|
||||
"一句话:": "In one sentence:",
|
||||
"从“想到”直接走到“可发布”。": "Go from idea to publish-ready.",
|
||||
"支持的创作主题": "Supported Creative Themes",
|
||||
"通用对话": "General Chat",
|
||||
"歌词曲谱": "Lyrics & Score",
|
||||
"小说创作": "Novel Writing",
|
||||
"3 步开始创作": "Start Creating in 3 Steps",
|
||||
"选主题:按目标进入对应创作主题": "Choose a theme: enter the matching creative theme by goal.",
|
||||
"给输入:一句需求、一个方向或一份素材都可以": "Provide input: a single requirement, a direction, or source material.",
|
||||
"持续迭代:边聊边改边沉淀,最终得到可发布结果": "Iterate continuously: chat, refine, and accumulate until publish-ready output.",
|
||||
"适合谁": "Who It's For",
|
||||
"自媒体创作者": "Independent Content Creators",
|
||||
"短视频团队": "Short-video Teams",
|
||||
"小说与剧情创作者": "Novel and Story Creators",
|
||||
"运营与品牌内容团队": "Operations and Brand Content Teams",
|
||||
"需要长期沉淀创作资产的个人与小团队": "Individuals and small teams who need long-term creative asset accumulation",
|
||||
"可选能力": "Optional Capabilities",
|
||||
"团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。": "The Team Gateway exposes OpenAI/Anthropic-compatible endpoints on LAN, enabling shared provider strategies and default models; it is optional for solo creators.",
|
||||
"常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。": "Common credential paths: Kiro `~/.kiro/kiro_creds.json`, Gemini CLI `~/.gemini/oauth_creds.json`, Qwen `~/.qwen-coder/auth.json`.",
|
||||
"本地工具环境(可选)": "Local Tool Environment (Optional)",
|
||||
"Made with love for creators & builders": "Made with love for creators & builders",
|
||||
"加载失败": "Load failed"
|
||||
}
|
||||
|
||||
@@ -3723,6 +3723,7 @@
|
||||
"准备就绪": "准备就绪",
|
||||
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
|
||||
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
|
||||
"新建图片": "新建图片",
|
||||
"// === Channels Settings (src/components/settings-v2/system/channels/) ===": "",
|
||||
"渠道管理": "渠道管理",
|
||||
"AI 模型提供商": "AI 模型提供商",
|
||||
@@ -3820,5 +3821,32 @@
|
||||
"Webhook URL 不能为空": "Webhook URL 不能为空",
|
||||
"Bot Token 不能为空": "Bot Token 不能为空",
|
||||
"聊天 ID 不能为空": "聊天 ID 不能为空",
|
||||
"创作类 AI Agent 平台": "创作类 AI Agent 平台",
|
||||
"把灵感、写作、出图、改稿、沉淀放进同一个工作台": "把灵感、写作、出图、改稿、沉淀放进同一个工作台",
|
||||
"版本 {{version}}": "版本 {{version}}",
|
||||
"有新版本 {{version}}": "有新版本 {{version}}",
|
||||
"产品定位": "产品定位",
|
||||
"ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。": "ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。",
|
||||
"一句话:": "一句话:",
|
||||
"从“想到”直接走到“可发布”。": "从“想到”直接走到“可发布”。",
|
||||
"支持的创作主题": "支持的创作主题",
|
||||
"通用对话": "通用对话",
|
||||
"歌词曲谱": "歌词曲谱",
|
||||
"小说创作": "小说创作",
|
||||
"3 步开始创作": "3 步开始创作",
|
||||
"选主题:按目标进入对应创作主题": "选主题:按目标进入对应创作主题",
|
||||
"给输入:一句需求、一个方向或一份素材都可以": "给输入:一句需求、一个方向或一份素材都可以",
|
||||
"持续迭代:边聊边改边沉淀,最终得到可发布结果": "持续迭代:边聊边改边沉淀,最终得到可发布结果",
|
||||
"适合谁": "适合谁",
|
||||
"自媒体创作者": "自媒体创作者",
|
||||
"短视频团队": "短视频团队",
|
||||
"小说与剧情创作者": "小说与剧情创作者",
|
||||
"运营与品牌内容团队": "运营与品牌内容团队",
|
||||
"需要长期沉淀创作资产的个人与小团队": "需要长期沉淀创作资产的个人与小团队",
|
||||
"可选能力": "可选能力",
|
||||
"团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。": "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。",
|
||||
"常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。": "常见凭证路径:Kiro `~/.kiro/kiro_creds.json`、Gemini CLI `~/.gemini/oauth_creds.json`、Qwen `~/.qwen-coder/auth.json`。",
|
||||
"本地工具环境(可选)": "本地工具环境(可选)",
|
||||
"Made with love for creators & builders": "Made with love for creators & builders",
|
||||
"加载失败": "加载失败"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const ACTIVE_CONTENT_TARGET_KEY = "proxycast-active-content-target";
|
||||
|
||||
export type ActiveCanvasType =
|
||||
| "document"
|
||||
| "novel"
|
||||
| "script"
|
||||
| "music"
|
||||
| "poster"
|
||||
| "video"
|
||||
| null;
|
||||
|
||||
export interface ActiveContentTarget {
|
||||
projectId: string | null;
|
||||
contentId: string | null;
|
||||
canvasType?: ActiveCanvasType;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const hasWindow = () => typeof window !== "undefined";
|
||||
|
||||
const normalizeId = (value: string | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeCanvasType = (
|
||||
value: string | null | undefined,
|
||||
): ActiveCanvasType => {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "document" ||
|
||||
normalized === "novel" ||
|
||||
normalized === "script" ||
|
||||
normalized === "music" ||
|
||||
normalized === "poster" ||
|
||||
normalized === "video"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getActiveContentTarget = (): ActiveContentTarget | null => {
|
||||
if (!hasWindow()) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(ACTIVE_CONTENT_TARGET_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<ActiveContentTarget>;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return {
|
||||
projectId: normalizeId(parsed.projectId),
|
||||
contentId: normalizeId(parsed.contentId),
|
||||
canvasType: normalizeCanvasType(
|
||||
typeof parsed.canvasType === "string" ? parsed.canvasType : null,
|
||||
),
|
||||
updatedAt:
|
||||
typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setActiveContentTarget = (
|
||||
projectId?: string | null,
|
||||
contentId?: string | null,
|
||||
canvasType?: ActiveCanvasType,
|
||||
) => {
|
||||
if (!hasWindow()) return;
|
||||
const payload: ActiveContentTarget = {
|
||||
projectId: normalizeId(projectId),
|
||||
contentId: normalizeId(contentId),
|
||||
canvasType: normalizeCanvasType(canvasType || null),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (!payload.projectId && !payload.contentId) {
|
||||
localStorage.removeItem(ACTIVE_CONTENT_TARGET_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(ACTIVE_CONTENT_TARGET_KEY, JSON.stringify(payload));
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
const CANVAS_IMAGE_INSERT_EVENT = "proxycast:canvas-image-insert-request";
|
||||
const CANVAS_IMAGE_INSERT_ACK_EVENT = "proxycast:canvas-image-insert-ack";
|
||||
const CANVAS_IMAGE_INSERT_QUEUE_KEY = "proxycast:canvas-image-insert-queue";
|
||||
const MAX_QUEUE_SIZE = 40;
|
||||
|
||||
export type CanvasImageInsertSource =
|
||||
| "pexels"
|
||||
| "pixabay"
|
||||
| "gallery"
|
||||
| "manual";
|
||||
|
||||
export type CanvasImageTargetType =
|
||||
| "auto"
|
||||
| "document"
|
||||
| "novel"
|
||||
| "script"
|
||||
| "music"
|
||||
| "poster"
|
||||
| "video";
|
||||
|
||||
export type CanvasImageInsertAnchorHint =
|
||||
| "cursor"
|
||||
| "section_end"
|
||||
| "scene_end"
|
||||
| "lyrics_end"
|
||||
| "poster_center"
|
||||
| "video_start_frame";
|
||||
|
||||
export interface InsertableImage {
|
||||
id: string;
|
||||
previewUrl: string;
|
||||
contentUrl: string;
|
||||
pageUrl?: string;
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
attributionName?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface CanvasImageInsertRequest {
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
projectId: string | null;
|
||||
contentId: string | null;
|
||||
canvasType: CanvasImageTargetType;
|
||||
anchorHint?: CanvasImageInsertAnchorHint;
|
||||
source: CanvasImageInsertSource;
|
||||
image: InsertableImage;
|
||||
}
|
||||
|
||||
export interface EmitCanvasImageInsertRequestInput {
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
canvasType?: CanvasImageTargetType;
|
||||
anchorHint?: CanvasImageInsertAnchorHint;
|
||||
source: CanvasImageInsertSource;
|
||||
image: InsertableImage;
|
||||
}
|
||||
|
||||
export interface CanvasImageInsertAck {
|
||||
requestId: string;
|
||||
processedAt: number;
|
||||
success: boolean;
|
||||
canvasType: CanvasImageTargetType;
|
||||
locationLabel?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const hasWindow = () => typeof window !== "undefined";
|
||||
|
||||
const normalizeId = (value: string | null | undefined): string | null => {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const VALID_CANVAS_TYPES = new Set<CanvasImageTargetType>([
|
||||
"auto",
|
||||
"document",
|
||||
"novel",
|
||||
"script",
|
||||
"music",
|
||||
"poster",
|
||||
"video",
|
||||
]);
|
||||
|
||||
const normalizeCanvasType = (
|
||||
value: CanvasImageTargetType | string | null | undefined,
|
||||
): CanvasImageTargetType => {
|
||||
if (!value) return "auto";
|
||||
const normalized = value.trim().toLowerCase() as CanvasImageTargetType;
|
||||
return VALID_CANVAS_TYPES.has(normalized) ? normalized : "auto";
|
||||
};
|
||||
|
||||
const createRequest = (
|
||||
input: EmitCanvasImageInsertRequestInput,
|
||||
): CanvasImageInsertRequest => ({
|
||||
requestId: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
projectId: normalizeId(input.projectId),
|
||||
contentId: normalizeId(input.contentId),
|
||||
canvasType: normalizeCanvasType(input.canvasType),
|
||||
anchorHint: input.anchorHint,
|
||||
source: input.source,
|
||||
image: input.image,
|
||||
});
|
||||
|
||||
const readQueue = (): CanvasImageInsertRequest[] => {
|
||||
if (!hasWindow()) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(CANVAS_IMAGE_INSERT_QUEUE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((item): item is CanvasImageInsertRequest => {
|
||||
return (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.requestId === "string" &&
|
||||
typeof item.createdAt === "number" &&
|
||||
typeof item.source === "string" &&
|
||||
item.image &&
|
||||
typeof item.image.contentUrl === "string"
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeQueue = (queue: CanvasImageInsertRequest[]) => {
|
||||
if (!hasWindow()) return;
|
||||
localStorage.setItem(
|
||||
CANVAS_IMAGE_INSERT_QUEUE_KEY,
|
||||
JSON.stringify(queue.slice(-MAX_QUEUE_SIZE)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getPendingCanvasImageInsertRequests = () => readQueue();
|
||||
|
||||
export const ackCanvasImageInsertRequest = (requestId: string): void => {
|
||||
if (!requestId) return;
|
||||
const queue = readQueue();
|
||||
const nextQueue = queue.filter((item) => item.requestId !== requestId);
|
||||
if (nextQueue.length === queue.length) return;
|
||||
writeQueue(nextQueue);
|
||||
};
|
||||
|
||||
export const emitCanvasImageInsertRequest = (
|
||||
input: EmitCanvasImageInsertRequestInput,
|
||||
): CanvasImageInsertRequest => {
|
||||
const request = createRequest(input);
|
||||
const queue = readQueue();
|
||||
queue.push(request);
|
||||
writeQueue(queue);
|
||||
|
||||
if (hasWindow()) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CanvasImageInsertRequest>(CANVAS_IMAGE_INSERT_EVENT, {
|
||||
detail: request,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
export const onCanvasImageInsertRequest = (
|
||||
listener: (request: CanvasImageInsertRequest) => void,
|
||||
): (() => void) => {
|
||||
if (!hasWindow()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
return;
|
||||
}
|
||||
const request = (event as CustomEvent<CanvasImageInsertRequest>).detail;
|
||||
if (!request || typeof request.requestId !== "string") {
|
||||
return;
|
||||
}
|
||||
listener(request);
|
||||
};
|
||||
|
||||
window.addEventListener(CANVAS_IMAGE_INSERT_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(CANVAS_IMAGE_INSERT_EVENT, handler);
|
||||
};
|
||||
};
|
||||
|
||||
export const emitCanvasImageInsertAck = (ack: Omit<CanvasImageInsertAck, "processedAt">) => {
|
||||
if (!hasWindow()) return;
|
||||
const payload: CanvasImageInsertAck = {
|
||||
...ack,
|
||||
processedAt: Date.now(),
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CanvasImageInsertAck>(CANVAS_IMAGE_INSERT_ACK_EVENT, {
|
||||
detail: payload,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const onCanvasImageInsertAck = (
|
||||
listener: (ack: CanvasImageInsertAck) => void,
|
||||
): (() => void) => {
|
||||
if (!hasWindow()) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
return;
|
||||
}
|
||||
const ack = (event as CustomEvent<CanvasImageInsertAck>).detail;
|
||||
if (!ack || typeof ack.requestId !== "string") {
|
||||
return;
|
||||
}
|
||||
listener(ack);
|
||||
};
|
||||
|
||||
window.addEventListener(CANVAS_IMAGE_INSERT_ACK_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(CANVAS_IMAGE_INSERT_ACK_EVENT, handler);
|
||||
};
|
||||
};
|
||||
|
||||
export const matchesCanvasImageInsertTarget = (
|
||||
request: CanvasImageInsertRequest,
|
||||
target: {
|
||||
projectId?: string | null;
|
||||
contentId?: string | null;
|
||||
canvasType: CanvasImageTargetType;
|
||||
},
|
||||
): boolean => {
|
||||
if (request.projectId && request.projectId !== (target.projectId || null)) {
|
||||
return false;
|
||||
}
|
||||
if (request.contentId && request.contentId !== (target.contentId || null)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
request.canvasType !== "auto" &&
|
||||
request.canvasType !== target.canvasType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CanvasImageTargetType } from "@/lib/canvasImageInsertBus";
|
||||
|
||||
const INSERT_HISTORY_STORAGE_KEY = "proxycast:canvas-image-insert-history";
|
||||
const MAX_HISTORY_SIZE = 30;
|
||||
|
||||
export interface CanvasImageInsertHistoryEntry {
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
projectId: string;
|
||||
contentId: string | null;
|
||||
canvasType: CanvasImageTargetType;
|
||||
theme: string;
|
||||
imageTitle?: string;
|
||||
locationLabel?: string;
|
||||
}
|
||||
|
||||
const hasWindow = () => typeof window !== "undefined";
|
||||
|
||||
function normalizeId(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getCanvasImageInsertHistory(): CanvasImageInsertHistoryEntry[] {
|
||||
if (!hasWindow()) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(INSERT_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is CanvasImageInsertHistoryEntry => {
|
||||
return (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.requestId === "string" &&
|
||||
typeof item.createdAt === "number" &&
|
||||
typeof item.projectId === "string" &&
|
||||
typeof item.canvasType === "string" &&
|
||||
typeof item.theme === "string"
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(history: CanvasImageInsertHistoryEntry[]): void {
|
||||
if (!hasWindow()) return;
|
||||
localStorage.setItem(
|
||||
INSERT_HISTORY_STORAGE_KEY,
|
||||
JSON.stringify(history.slice(0, MAX_HISTORY_SIZE)),
|
||||
);
|
||||
}
|
||||
|
||||
export function addCanvasImageInsertHistory(
|
||||
entry: Omit<CanvasImageInsertHistoryEntry, "createdAt"> & {
|
||||
createdAt?: number;
|
||||
},
|
||||
): CanvasImageInsertHistoryEntry[] {
|
||||
const normalizedProjectId = normalizeId(entry.projectId);
|
||||
if (!normalizedProjectId) {
|
||||
return getCanvasImageInsertHistory();
|
||||
}
|
||||
|
||||
const history = getCanvasImageInsertHistory();
|
||||
const payload: CanvasImageInsertHistoryEntry = {
|
||||
requestId: entry.requestId,
|
||||
createdAt: entry.createdAt ?? Date.now(),
|
||||
projectId: normalizedProjectId,
|
||||
contentId: normalizeId(entry.contentId),
|
||||
canvasType: entry.canvasType,
|
||||
theme: entry.theme,
|
||||
imageTitle: entry.imageTitle,
|
||||
locationLabel: entry.locationLabel,
|
||||
};
|
||||
|
||||
const nextHistory = [
|
||||
payload,
|
||||
...history.filter((item) => item.requestId !== payload.requestId),
|
||||
];
|
||||
writeHistory(nextHistory);
|
||||
return nextHistory;
|
||||
}
|
||||
|
||||
export function clearCanvasImageInsertHistory(): void {
|
||||
if (!hasWindow()) return;
|
||||
localStorage.removeItem(INSERT_HISTORY_STORAGE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
CrashReportingConfig,
|
||||
LogEntry,
|
||||
} from "@/hooks/useTauri";
|
||||
|
||||
export interface CrashDiagnosticPayload {
|
||||
generated_at: string;
|
||||
app_version: string;
|
||||
platform: string;
|
||||
user_agent: string;
|
||||
crash_reporting: CrashReportingConfig;
|
||||
frontend_crash_logs: LogEntry[];
|
||||
}
|
||||
|
||||
export const DEFAULT_CRASH_REPORTING_CONFIG: CrashReportingConfig = {
|
||||
enabled: true,
|
||||
dsn: null,
|
||||
environment: "production",
|
||||
sample_rate: 1,
|
||||
send_pii: false,
|
||||
};
|
||||
|
||||
export function normalizeCrashReportingConfig(
|
||||
config?: CrashReportingConfig,
|
||||
): CrashReportingConfig {
|
||||
return {
|
||||
enabled: config?.enabled ?? DEFAULT_CRASH_REPORTING_CONFIG.enabled,
|
||||
dsn: config?.dsn ?? DEFAULT_CRASH_REPORTING_CONFIG.dsn,
|
||||
environment:
|
||||
config?.environment?.trim() ||
|
||||
DEFAULT_CRASH_REPORTING_CONFIG.environment,
|
||||
sample_rate:
|
||||
typeof config?.sample_rate === "number" &&
|
||||
Number.isFinite(config.sample_rate)
|
||||
? Math.min(1, Math.max(0, config.sample_rate))
|
||||
: DEFAULT_CRASH_REPORTING_CONFIG.sample_rate,
|
||||
send_pii: config?.send_pii ?? DEFAULT_CRASH_REPORTING_CONFIG.send_pii,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskCrashReportingDsn(dsn?: string | null): string | null {
|
||||
if (!dsn) return null;
|
||||
const trimmed = dsn.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length <= 12) return "***";
|
||||
return `${trimmed.slice(0, 10)}***${trimmed.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function pickFrontendCrashLogs(
|
||||
logs: LogEntry[],
|
||||
limit = 30,
|
||||
): LogEntry[] {
|
||||
return logs
|
||||
.filter((entry) => entry.message.includes("[FrontendCrash]"))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
interface BuildCrashDiagnosticPayloadParams {
|
||||
crashConfig: CrashReportingConfig;
|
||||
logs: LogEntry[];
|
||||
appVersion?: string;
|
||||
platform: string;
|
||||
userAgent: string;
|
||||
maxCrashLogs?: number;
|
||||
}
|
||||
|
||||
export function buildCrashDiagnosticPayload(
|
||||
params: BuildCrashDiagnosticPayloadParams,
|
||||
): CrashDiagnosticPayload {
|
||||
const {
|
||||
crashConfig,
|
||||
logs,
|
||||
appVersion,
|
||||
platform,
|
||||
userAgent,
|
||||
maxCrashLogs = 30,
|
||||
} = params;
|
||||
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
app_version: appVersion || "unknown",
|
||||
platform,
|
||||
user_agent: userAgent,
|
||||
crash_reporting: {
|
||||
...normalizeCrashReportingConfig(crashConfig),
|
||||
dsn: maskCrashReportingDsn(crashConfig.dsn),
|
||||
},
|
||||
frontend_crash_logs: pickFrontendCrashLogs(logs, maxCrashLogs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyCrashDiagnosticToClipboard(
|
||||
payload: CrashDiagnosticPayload,
|
||||
): Promise<void> {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
export function exportCrashDiagnosticToJson(
|
||||
payload: CrashDiagnosticPayload,
|
||||
): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `proxycast-crash-diagnostic-${Date.now()}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeCrashValue } from "./crashReporting";
|
||||
|
||||
describe("crashReporting sanitizeCrashValue", () => {
|
||||
it("应脱敏常见密钥与 token", () => {
|
||||
const sanitized = sanitizeCrashValue({
|
||||
authorization: "Bearer sk-1234567890abcdef",
|
||||
apiKey: "api_key=sk-abcdef1234567890",
|
||||
token: "refresh_token=abcdefg1234567890",
|
||||
nested: {
|
||||
message: "access_token: abcdefghijklmnopqrst",
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(String(sanitized.authorization)).toContain("Bearer ***");
|
||||
expect(String(sanitized.apiKey)).toContain("api_key=***");
|
||||
expect(String(sanitized.token)).toContain("refresh_token=***");
|
||||
expect(String((sanitized.nested as Record<string, unknown>).message)).toContain(
|
||||
"access_token=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("应保留数字和布尔值结构", () => {
|
||||
const sanitized = sanitizeCrashValue({
|
||||
count: 3,
|
||||
ok: true,
|
||||
list: [1, false, "Bearer abcd1234"],
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(sanitized.count).toBe(3);
|
||||
expect(sanitized.ok).toBe(true);
|
||||
expect(Array.isArray(sanitized.list)).toBe(true);
|
||||
expect(String((sanitized.list as unknown[])[2])).toContain("Bearer ***");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import {
|
||||
getConfig,
|
||||
type Config,
|
||||
type CrashReportingConfig as TauriCrashReportingConfig,
|
||||
} from "@/hooks/useTauri";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import configEventManager from "@/lib/configEventManager";
|
||||
|
||||
export type CrashContext = Record<string, unknown>;
|
||||
|
||||
interface ResolvedCrashReportingConfig {
|
||||
enabled: boolean;
|
||||
dsn: string | null;
|
||||
environment: string;
|
||||
sampleRate: number;
|
||||
sendPii: boolean;
|
||||
}
|
||||
|
||||
interface FrontendCrashReportPayload {
|
||||
message: string;
|
||||
name?: string;
|
||||
stack?: string;
|
||||
component?: string;
|
||||
workflow_step?: string;
|
||||
creation_mode?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"],
|
||||
[/\bapi[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "api_key=***"],
|
||||
[/\baccess[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "access_token=***"],
|
||||
[/\brefresh[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "refresh_token=***"],
|
||||
[/\btoken\s*[:=]\s*["']?[A-Za-z0-9._-]{10,}["']?/gi, "token=***"],
|
||||
[/\bsk-[A-Za-z0-9]{12,}\b/g, "sk-***"],
|
||||
];
|
||||
|
||||
const DEFAULT_CRASH_REPORTING_CONFIG: ResolvedCrashReportingConfig = {
|
||||
enabled: true,
|
||||
dsn: null,
|
||||
environment: import.meta.env.DEV ? "development" : "production",
|
||||
sampleRate: 1,
|
||||
sendPii: false,
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
let sentryEnabled = false;
|
||||
let listenersAttached = false;
|
||||
let runtimeContext: CrashContext = {};
|
||||
let currentResolvedConfig: ResolvedCrashReportingConfig = {
|
||||
...DEFAULT_CRASH_REPORTING_CONFIG,
|
||||
};
|
||||
let configListenerDisposer: (() => void) | null = null;
|
||||
|
||||
function sanitizeText(input: string): string {
|
||||
let sanitized = input;
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, replacement);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sanitizeCrashValue(value: unknown, depth = 0): unknown {
|
||||
if (depth > 5) {
|
||||
return "[depth_limited]";
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return sanitizeText(value);
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeCrashValue(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
output[key] = sanitizeCrashValue(nestedValue, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function resolveCrashReportingConfig(
|
||||
config: Config | null,
|
||||
): ResolvedCrashReportingConfig {
|
||||
const crashConfig: TauriCrashReportingConfig | undefined =
|
||||
config?.crash_reporting;
|
||||
|
||||
const dsnCandidate =
|
||||
crashConfig?.dsn ??
|
||||
(import.meta.env.VITE_SENTRY_DSN as string | undefined) ??
|
||||
null;
|
||||
|
||||
const dsn = typeof dsnCandidate === "string" && dsnCandidate.trim()
|
||||
? dsnCandidate.trim()
|
||||
: null;
|
||||
|
||||
const sampleRateRaw = Number(
|
||||
crashConfig?.sample_rate ?? DEFAULT_CRASH_REPORTING_CONFIG.sampleRate,
|
||||
);
|
||||
const sampleRate = Number.isFinite(sampleRateRaw)
|
||||
? Math.min(1, Math.max(0, sampleRateRaw))
|
||||
: DEFAULT_CRASH_REPORTING_CONFIG.sampleRate;
|
||||
|
||||
const environment =
|
||||
crashConfig?.environment?.trim() ||
|
||||
DEFAULT_CRASH_REPORTING_CONFIG.environment;
|
||||
|
||||
return {
|
||||
enabled: crashConfig?.enabled ?? DEFAULT_CRASH_REPORTING_CONFIG.enabled,
|
||||
dsn,
|
||||
environment,
|
||||
sampleRate,
|
||||
sendPii: crashConfig?.send_pii ?? DEFAULT_CRASH_REPORTING_CONFIG.sendPii,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return new Error(error);
|
||||
}
|
||||
return new Error("Unknown frontend error");
|
||||
}
|
||||
|
||||
function getRuntimeTags(context: CrashContext): Record<string, string> {
|
||||
const platform =
|
||||
typeof navigator === "undefined"
|
||||
? "unknown"
|
||||
: sanitizeText(navigator.platform || "unknown");
|
||||
|
||||
const tags: Record<string, string> = {
|
||||
platform,
|
||||
app_version:
|
||||
(import.meta.env.VITE_APP_VERSION as string | undefined)?.trim() ||
|
||||
"unknown",
|
||||
};
|
||||
|
||||
const workflowStep = context.workflow_step;
|
||||
const creationMode = context.creation_mode;
|
||||
|
||||
if (typeof workflowStep === "string" && workflowStep.trim()) {
|
||||
tags.workflow_step = sanitizeText(workflowStep);
|
||||
}
|
||||
if (typeof creationMode === "string" && creationMode.trim()) {
|
||||
tags.creation_mode = sanitizeText(creationMode);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function attachGlobalErrorListeners(): void {
|
||||
if (listenersAttached || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
listenersAttached = true;
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const normalized = normalizeError(
|
||||
event.error ?? event.message ?? "window error",
|
||||
);
|
||||
void reportFrontendError(normalized, {
|
||||
source: "window.onerror",
|
||||
workflow_step: "global_runtime",
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const normalized = normalizeError(event.reason);
|
||||
void reportFrontendError(normalized, {
|
||||
source: "window.unhandledrejection",
|
||||
workflow_step: "global_promise",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRuntimeConfig(): Promise<ResolvedCrashReportingConfig> {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
return resolveCrashReportingConfig(config);
|
||||
} catch (error) {
|
||||
console.warn("[CrashReporting] 读取配置失败,使用默认配置:", error);
|
||||
return { ...DEFAULT_CRASH_REPORTING_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function buildCrashReportPayload(
|
||||
error: Error,
|
||||
context: CrashContext,
|
||||
): FrontendCrashReportPayload {
|
||||
const mergedContext = sanitizeCrashValue(context) as Record<string, unknown>;
|
||||
const message = sanitizeText(error.message || "Unknown frontend error");
|
||||
|
||||
return {
|
||||
message,
|
||||
name: sanitizeText(error.name || "Error"),
|
||||
stack: error.stack ? sanitizeText(error.stack) : undefined,
|
||||
component:
|
||||
typeof mergedContext.component === "string"
|
||||
? String(mergedContext.component)
|
||||
: undefined,
|
||||
workflow_step:
|
||||
typeof mergedContext.workflow_step === "string"
|
||||
? String(mergedContext.workflow_step)
|
||||
: undefined,
|
||||
creation_mode:
|
||||
typeof mergedContext.creation_mode === "string"
|
||||
? String(mergedContext.creation_mode)
|
||||
: undefined,
|
||||
context: mergedContext,
|
||||
};
|
||||
}
|
||||
|
||||
function persistFrontendCrash(report: FrontendCrashReportPayload): void {
|
||||
void safeInvoke("report_frontend_crash", { report }).catch((error) => {
|
||||
console.warn("[CrashReporting] 写入后端崩溃日志失败:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function isSameResolvedConfig(
|
||||
left: ResolvedCrashReportingConfig,
|
||||
right: ResolvedCrashReportingConfig,
|
||||
): boolean {
|
||||
return (
|
||||
left.enabled === right.enabled &&
|
||||
left.dsn === right.dsn &&
|
||||
left.environment === right.environment &&
|
||||
left.sampleRate === right.sampleRate &&
|
||||
left.sendPii === right.sendPii
|
||||
);
|
||||
}
|
||||
|
||||
async function disableSentryClient(reason: string): Promise<void> {
|
||||
if (Sentry.getClient()) {
|
||||
try {
|
||||
await Sentry.close(2000);
|
||||
} catch (error) {
|
||||
console.warn("[CrashReporting] 关闭 Sentry 客户端失败:", error);
|
||||
}
|
||||
}
|
||||
sentryEnabled = false;
|
||||
console.info(`[CrashReporting] 远端上报已停用: ${reason}`);
|
||||
}
|
||||
|
||||
function initSentryClient(config: ResolvedCrashReportingConfig): void {
|
||||
Sentry.init({
|
||||
dsn: config.dsn as string,
|
||||
enabled: true,
|
||||
environment: config.environment,
|
||||
sampleRate: config.sampleRate,
|
||||
sendDefaultPii: config.sendPii,
|
||||
defaultIntegrations: false,
|
||||
beforeSend(event: any) {
|
||||
const sanitizedEvent = sanitizeCrashValue(event) as Sentry.Event;
|
||||
const tags = {
|
||||
...(sanitizedEvent.tags ?? {}),
|
||||
...getRuntimeTags(runtimeContext),
|
||||
};
|
||||
sanitizedEvent.tags = tags;
|
||||
return sanitizedEvent as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function applyResolvedConfig(
|
||||
nextConfig: ResolvedCrashReportingConfig,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const unchanged = isSameResolvedConfig(currentResolvedConfig, nextConfig);
|
||||
currentResolvedConfig = { ...nextConfig };
|
||||
|
||||
const shouldEnableRemote = nextConfig.enabled && Boolean(nextConfig.dsn);
|
||||
if (!shouldEnableRemote) {
|
||||
if (sentryEnabled || Sentry.getClient()) {
|
||||
await disableSentryClient(reason);
|
||||
} else {
|
||||
sentryEnabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!unchanged && Sentry.getClient()) {
|
||||
await disableSentryClient("reconfigure");
|
||||
} else if (unchanged && sentryEnabled && Sentry.getClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
initSentryClient(nextConfig);
|
||||
sentryEnabled = true;
|
||||
console.info(
|
||||
`[CrashReporting] Sentry Browser SDK 初始化成功: env=${nextConfig.environment}, sampleRate=${nextConfig.sampleRate}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshRuntimeConfig(reason: string): Promise<void> {
|
||||
const runtimeConfig = await loadRuntimeConfig();
|
||||
await applyResolvedConfig(runtimeConfig, reason);
|
||||
}
|
||||
|
||||
async function bindConfigChangeListener(): Promise<void> {
|
||||
if (configListenerDisposer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await configEventManager.subscribe();
|
||||
configListenerDisposer = configEventManager.addCallback(() => {
|
||||
void refreshRuntimeConfig("config-changed");
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[CrashReporting] 绑定配置变更监听失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCrashReporting(): Promise<void> {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
attachGlobalErrorListeners();
|
||||
await refreshRuntimeConfig("startup");
|
||||
await bindConfigChangeListener();
|
||||
}
|
||||
|
||||
export async function applyCrashReportingSettings(
|
||||
crashConfig?: TauriCrashReportingConfig | null,
|
||||
): Promise<void> {
|
||||
const pseudoConfig = { crash_reporting: crashConfig ?? undefined } as Config;
|
||||
const resolved = resolveCrashReportingConfig(pseudoConfig);
|
||||
await applyResolvedConfig(resolved, "manual-update");
|
||||
}
|
||||
|
||||
export function updateCrashContext(context: CrashContext): void {
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
delete runtimeContext[key];
|
||||
} else {
|
||||
runtimeContext[key] = sanitizeCrashValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCrashContext(keys?: string[]): void {
|
||||
if (!keys || keys.length === 0) {
|
||||
runtimeContext = {};
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
delete runtimeContext[key];
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportFrontendError(
|
||||
error: unknown,
|
||||
context: CrashContext = {},
|
||||
): Promise<void> {
|
||||
const normalizedError = normalizeError(error);
|
||||
const mergedContext = {
|
||||
...runtimeContext,
|
||||
...context,
|
||||
};
|
||||
const payload = buildCrashReportPayload(normalizedError, mergedContext);
|
||||
persistFrontendCrash(payload);
|
||||
|
||||
if (!sentryEnabled || !Sentry.getClient()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
const tags = getRuntimeTags(payload.context ?? {});
|
||||
for (const [key, value] of Object.entries(tags)) {
|
||||
scope.setTag(key, value);
|
||||
}
|
||||
scope.setTag("origin", "frontend");
|
||||
scope.setContext(
|
||||
"frontend_context",
|
||||
(payload.context ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
Sentry.captureException(normalizedError);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
ackCanvasImageInsertRequest,
|
||||
emitCanvasImageInsertRequest,
|
||||
getPendingCanvasImageInsertRequests,
|
||||
onCanvasImageInsertRequest,
|
||||
type CanvasImageInsertRequest,
|
||||
type CanvasImageInsertSource,
|
||||
type EmitCanvasImageInsertRequestInput,
|
||||
type InsertableImage,
|
||||
} from "@/lib/canvasImageInsertBus";
|
||||
|
||||
export type DocumentImageInsertSource = CanvasImageInsertSource;
|
||||
export type DocumentImageInsertRequest = CanvasImageInsertRequest;
|
||||
export type EmitDocumentImageInsertRequestInput = Omit<
|
||||
EmitCanvasImageInsertRequestInput,
|
||||
"canvasType"
|
||||
>;
|
||||
export type { InsertableImage };
|
||||
|
||||
export const getPendingDocumentImageInsertRequests = (): DocumentImageInsertRequest[] =>
|
||||
getPendingCanvasImageInsertRequests().filter(
|
||||
(request) =>
|
||||
request.canvasType === "document" || request.canvasType === "auto",
|
||||
);
|
||||
|
||||
export const ackDocumentImageInsertRequest = (requestId: string): void => {
|
||||
ackCanvasImageInsertRequest(requestId);
|
||||
};
|
||||
|
||||
export const emitDocumentImageInsertRequest = (
|
||||
input: EmitDocumentImageInsertRequestInput,
|
||||
): DocumentImageInsertRequest =>
|
||||
emitCanvasImageInsertRequest({
|
||||
...input,
|
||||
canvasType: "document",
|
||||
});
|
||||
|
||||
export const onDocumentImageInsertRequest = (
|
||||
listener: (request: DocumentImageInsertRequest) => void,
|
||||
): (() => void) =>
|
||||
onCanvasImageInsertRequest((request) => {
|
||||
if (request.canvasType !== "document" && request.canvasType !== "auto") {
|
||||
return;
|
||||
}
|
||||
listener(request);
|
||||
});
|
||||
@@ -83,6 +83,24 @@ const defaultMocks: Record<string, any> = {
|
||||
web_search: {
|
||||
engine: "google",
|
||||
},
|
||||
image_gen: {
|
||||
default_service: "dall_e",
|
||||
default_count: 1,
|
||||
default_size: "1024x1024",
|
||||
default_quality: "standard",
|
||||
default_style: "vivid",
|
||||
enable_enhancement: false,
|
||||
auto_download: false,
|
||||
image_search_pexels_api_key: "",
|
||||
image_search_pixabay_api_key: "",
|
||||
},
|
||||
crash_reporting: {
|
||||
enabled: true,
|
||||
dsn: null,
|
||||
environment: "development",
|
||||
sample_rate: 1.0,
|
||||
send_pii: false,
|
||||
},
|
||||
}),
|
||||
|
||||
save_config: (config: any) => {
|
||||
@@ -387,6 +405,16 @@ const defaultMocks: Record<string, any> = {
|
||||
get_video_generation_task: () => null,
|
||||
list_video_generation_tasks: () => [],
|
||||
cancel_video_generation_task: () => null,
|
||||
search_pixabay_images: () => ({
|
||||
total: 0,
|
||||
total_hits: 0,
|
||||
hits: [],
|
||||
}),
|
||||
search_web_images: () => ({
|
||||
total: 0,
|
||||
provider: "pexels",
|
||||
hits: [],
|
||||
}),
|
||||
import_material_from_url: () => ({
|
||||
id: "mock-material-id",
|
||||
}),
|
||||
@@ -524,6 +552,7 @@ const defaultMocks: Record<string, any> = {
|
||||
get_request_logs: () => ({ logs: [] }),
|
||||
get_request_log_detail: () => ({ log: null }),
|
||||
clear_request_logs: () => ({ success: true }),
|
||||
report_frontend_crash: () => ({ success: true }),
|
||||
get_stats_summary: () => ({ summary: {} }),
|
||||
get_stats_by_provider: () => ({ stats: [] }),
|
||||
get_stats_by_model: () => ({ stats: [] }),
|
||||
|
||||
@@ -7,9 +7,12 @@ import "./lib/tauri-mock/index";
|
||||
|
||||
// Initialize i18n configuration
|
||||
import "./i18n/config";
|
||||
import { initCrashReporting } from "@/lib/crashReporting";
|
||||
|
||||
// 注册 Artifact 轻量渲染器
|
||||
import { registerLightweightRenderers } from "./components/artifact";
|
||||
registerLightweightRenderers();
|
||||
|
||||
void initCrashReporting();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<RootRouter />);
|
||||
|
||||
@@ -41,7 +41,6 @@ export enum SettingsTabs {
|
||||
Channels = "channels",
|
||||
WebSearch = "web-search",
|
||||
ChromeRelay = "chrome-relay",
|
||||
Proxy = "proxy",
|
||||
SecurityPerformance = "security-performance",
|
||||
Heartbeat = "heartbeat",
|
||||
ExecutionTracker = "execution-tracker",
|
||||
@@ -93,7 +92,6 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
|
||||
SettingsTabs.Channels,
|
||||
SettingsTabs.WebSearch,
|
||||
SettingsTabs.ChromeRelay,
|
||||
SettingsTabs.Proxy,
|
||||
SettingsTabs.SecurityPerformance,
|
||||
SettingsTabs.Heartbeat,
|
||||
SettingsTabs.ExecutionTracker,
|
||||
|
||||
Reference in New Issue
Block a user