From dabb5c87b6005b06704ccb7ed353ff0be836c2a1 Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 2 Mar 2026 02:30:38 +0800 Subject: [PATCH] feat: finalize image search workflow and release v0.76.0 --- RELEASE_NOTES.md | 10 +- package.json | 3 +- src-tauri/Cargo.lock | 536 +++++- src-tauri/Cargo.toml | 6 +- src-tauri/crates/core/src/config/mod.rs | 16 +- src-tauri/crates/core/src/config/types.rs | 59 + src-tauri/src/app/commands/config.rs | 24 +- src-tauri/src/app/commands/logs.rs | 91 + src-tauri/src/app/runner.rs | 7 + src-tauri/src/commands/image_search_cmd.rs | 481 ++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/config/observer/mod.rs | 3 +- src-tauri/src/config/tests.rs | 3 + src-tauri/src/crash_reporting.rs | 91 + src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 2 + src/components/AppSidebar.tsx | 4 +- .../agent/chat/hooks/useAgentChat.ts | 1 - .../agent/chat/hooks/useAsterAgentChat.ts | 1 - src/components/agent/chat/index.tsx | 11 + .../artifact/ArtifactToolbar.test.ts | 6 +- src/components/artifact/ArtifactToolbar.tsx | 6 +- .../content-creator/canvas/CanvasFactory.tsx | 22 + .../canvas/canvasUtils.test.ts | 8 +- .../canvas/document/DocumentCanvas.tsx | 331 ++++ .../canvas/document/DocumentToolbar.tsx | 11 + .../canvas/document/editor/NotionEditor.tsx | 42 +- .../content-creator/canvas/document/types.ts | 10 + .../document/utils/autoImageInsert.test.ts | 74 + .../canvas/document/utils/autoImageInsert.ts | 219 +++ .../canvas/music/MusicCanvas.tsx | 147 +- .../content-creator/canvas/music/types.ts | 4 + .../canvas/novel/NovelCanvas.tsx | 157 +- .../canvas/poster/PosterCanvas.tsx | 71 +- .../content-creator/canvas/poster/types.ts | 4 + .../canvas/script/ScriptCanvas.tsx | 143 +- .../canvas/video/VideoCanvas.tsx | 70 +- .../content-creator/canvas/video/types.ts | 1 + .../general-chat/chat/ErrorBoundary.tsx | 11 +- src/components/image-gen/ImageGenPage.tsx | 1497 ++--------------- .../image-gen/RecentImageInsertFloating.tsx | 272 +++ .../image-gen/hooks/useImageSearch.test.tsx | 284 ++++ .../image-gen/hooks/useImageSearch.ts | 402 +++++ .../image-gen/tabs/AiImageGenTab.tsx | 1476 ++++++++++++++++ .../image-gen/tabs/ImageSearchTab.test.tsx | 313 ++++ .../image-gen/tabs/ImageSearchTab.tsx | 1133 +++++++++++++ .../image-gen/tabs/LocalImageTab.tsx | 343 ++++ .../image-gen/tabs/MyGalleryTab.test.tsx | 176 ++ .../image-gen/tabs/MyGalleryTab.tsx | 349 ++++ src/components/image-gen/tabs/index.ts | 16 + .../onboarding/OnboardingWizard.tsx | 131 +- .../onboarding/steps/CompleteStep.tsx | 87 +- .../onboarding/steps/PluginSelectStep.tsx | 35 +- .../onboarding/steps/UserProfileStep.tsx | 154 -- .../onboarding/steps/WelcomeStep.tsx | 148 +- src/components/settings-v2/_layout/index.tsx | 10 - .../agent/image-gen/index.test.tsx | 117 ++ .../general/chat-appearance/index.tsx | 2 +- .../settings-v2/hooks/useSettingsCategory.ts | 6 - .../settings-v2/system/about/index.tsx | 269 +-- .../settings-v2/system/developer/index.tsx | 125 +- .../settings-v2/system/experimental/index.tsx | 266 ++- .../settings-v2/system/proxy/index.tsx | 201 +-- .../system/web-search/index.test.tsx | 236 +++ .../settings-v2/system/web-search/index.tsx | 186 +- src/components/workspace/WorkbenchPage.tsx | 73 +- .../WorkbenchCreateContentDialog.test.tsx | 13 + .../dialogs/WorkbenchCreateContentDialog.tsx | 115 +- .../WorkbenchCreateContentDialogBoundary.tsx | 96 ++ src/components/workspace/dialogs/index.ts | 1 + .../hooks/useCreationDialogs.test.tsx | 46 + .../workspace/hooks/useCreationDialogs.ts | 222 ++- .../utils/creationIntentPrompt.test.ts | 11 + .../workspace/utils/creationIntentPrompt.ts | 122 +- src/hooks/useTauri.ts | 14 + src/i18n/patches/en.json | 27 + src/i18n/patches/zh.json | 28 + src/lib/activeContentTarget.ts | 85 + src/lib/canvasImageInsertBus.ts | 253 +++ src/lib/canvasImageInsertHistory.ts | 89 + src/lib/crashDiagnostic.ts | 112 ++ src/lib/crashReporting.test.ts | 35 + src/lib/crashReporting.ts | 400 +++++ src/lib/documentImageInsertBus.ts | 46 + src/lib/tauri-mock/core.ts | 29 + src/main.tsx | 3 + src/types/settings.ts | 2 - 88 files changed, 10474 insertions(+), 2272 deletions(-) create mode 100644 src-tauri/src/commands/image_search_cmd.rs create mode 100644 src-tauri/src/crash_reporting.rs create mode 100644 src/components/content-creator/canvas/document/utils/autoImageInsert.test.ts create mode 100644 src/components/content-creator/canvas/document/utils/autoImageInsert.ts create mode 100644 src/components/image-gen/RecentImageInsertFloating.tsx create mode 100644 src/components/image-gen/hooks/useImageSearch.test.tsx create mode 100644 src/components/image-gen/hooks/useImageSearch.ts create mode 100644 src/components/image-gen/tabs/AiImageGenTab.tsx create mode 100644 src/components/image-gen/tabs/ImageSearchTab.test.tsx create mode 100644 src/components/image-gen/tabs/ImageSearchTab.tsx create mode 100644 src/components/image-gen/tabs/LocalImageTab.tsx create mode 100644 src/components/image-gen/tabs/MyGalleryTab.test.tsx create mode 100644 src/components/image-gen/tabs/MyGalleryTab.tsx create mode 100644 src/components/image-gen/tabs/index.ts delete mode 100644 src/components/onboarding/steps/UserProfileStep.tsx create mode 100644 src/components/settings-v2/agent/image-gen/index.test.tsx create mode 100644 src/components/settings-v2/system/web-search/index.test.tsx create mode 100644 src/components/workspace/dialogs/WorkbenchCreateContentDialogBoundary.tsx create mode 100644 src/lib/activeContentTarget.ts create mode 100644 src/lib/canvasImageInsertBus.ts create mode 100644 src/lib/canvasImageInsertHistory.ts create mode 100644 src/lib/crashDiagnostic.ts create mode 100644 src/lib/crashReporting.test.ts create mode 100644 src/lib/crashReporting.ts create mode 100644 src/lib/documentImageInsertBus.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 753a1c539..4682d163e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/package.json b/package.json index 459650cf5..79f6a535a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d2a6c79a5..a4a859bb7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 581e27f5d..05d53cfe6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index fa2aa7f09..a2ee0e011 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -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, diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index ba37bc956..d045ed7fc 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -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, + /// 上报环境(如 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, + /// 图片搜索(Pexels)API Key + #[serde(default)] + pub image_search_pexels_api_key: Option, + /// 图片搜索(Pixabay)API Key + #[serde(default)] + pub image_search_pixabay_api_key: Option, } /// 助理配置 @@ -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] diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index 51c66cd3f..57aae9de5 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -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_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) } } } diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index 41733422c..5d8d6c6f8 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_step: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, +} /// 获取日志 #[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::>().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(()) +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 572f6fde4..a5d7cbd0d 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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, diff --git a/src-tauri/src/commands/image_search_cmd.rs b/src-tauri/src/commands/image_search_cmd.rs new file mode 100644 index 000000000..3b0d8737c --- /dev/null +++ b/src-tauri/src/commands/image_search_cmd.rs @@ -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, +} + +/// Pixabay 搜索响应 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PixabaySearchResponse { + /// 总结果数 + pub total: u32, + /// 总命中数 + pub total_hits: u32, + /// 搜索结果 + pub hits: Vec, +} + +/// 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, +} + +/// 联网图片搜索响应(统一格式) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebImageSearchResponse { + /// 总结果数 + pub total: u32, + /// 搜索结果 + pub hits: Vec, + /// 来源提供者标识 + 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, +} + +#[derive(Debug, Clone, Deserialize)] +struct PexelsPhoto { + id: u64, + width: u32, + height: u32, + url: String, + alt: Option, + src: PexelsPhotoSrc, +} + +#[derive(Debug, Clone, Deserialize)] +struct PexelsPhotoSrc { + tiny: Option, + small: Option, + medium: Option, + large: Option, + large2x: Option, + landscape: Option, + portrait: Option, + original: Option, +} + +/// 获取 Pixabay API Key(优先配置,其次环境变量) +async fn get_pixabay_api_key(app_state: State<'_, AppState>) -> Option { + 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 { + 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::>(); + + 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 { + 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::(&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::()); + 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 { + 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); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 34a306f92..6306b98fb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/config/observer/mod.rs b/src-tauri/src/config/observer/mod.rs index dbb410330..a5fd8c773 100644 --- a/src-tauri/src/config/observer/mod.rs +++ b/src-tauri/src/config/observer/mod.rs @@ -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 相关实现 diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index 42c9eea9b..1d1feb8d9 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -205,6 +205,7 @@ fn arb_config() -> impl Strategy { 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 { 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 { 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 { diff --git a/src-tauri/src/crash_reporting.rs b/src-tauri/src/crash_reporting.rs new file mode 100644 index 000000000..d3ef979ba --- /dev/null +++ b/src-tauri/src/crash_reporting.rs @@ -0,0 +1,91 @@ +//! 崩溃上报初始化与上报辅助(Sentry 协议兼容) + +use proxycast_core::config::{Config, CrashReportingConfig}; +use serde_json::Value; + +/// 根据配置初始化 Sentry 客户端 +pub fn init_from_config(config: &Config) -> Option { + init(&config.crash_reporting) +} + +fn init(config: &CrashReportingConfig) -> Option { + 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, +) { + 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); + }, + ); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25e692049..88b63fe64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,6 +46,7 @@ pub mod voice; // 内部模块 mod commands; mod config; +mod crash_reporting; mod data; #[cfg(debug_assertions)] #[allow(dead_code)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f90c31298..fd98f3123 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index f5845a61b..8238d1c76 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { )} {renderAllPages()} + 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; diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 9e8afcea1..2ed4fea9a 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -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); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index da7d6f6cf..e27ef6356 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -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)); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 9997cfaad..cee751c73 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -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(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, diff --git a/src/components/artifact/ArtifactToolbar.test.ts b/src/components/artifact/ArtifactToolbar.test.ts index c1956e480..f10db3596 100644 --- a/src/components/artifact/ArtifactToolbar.test.ts +++ b/src/components/artifact/ArtifactToolbar.test.ts @@ -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"; } /** diff --git a/src/components/artifact/ArtifactToolbar.tsx b/src/components/artifact/ArtifactToolbar.tsx index f5befb3be..2699454bf 100644 --- a/src/components/artifact/ArtifactToolbar.tsx +++ b/src/components/artifact/ArtifactToolbar.tsx @@ -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"; } /** diff --git a/src/components/content-creator/canvas/CanvasFactory.tsx b/src/components/content-creator/canvas/CanvasFactory.tsx index 46231d405..702894ef5 100644 --- a/src/components/content-creator/canvas/CanvasFactory.tsx +++ b/src/components/content-creator/canvas/CanvasFactory.tsx @@ -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 = memo( isStreaming, novelControls, onSelectionTextChange, + projectId, + contentId, + autoImageTopic, }) => { const resolvedBackHome = onBackHome ?? onClose; @@ -89,6 +98,9 @@ export const CanvasFactory: React.FC = memo( onClose={onClose} isStreaming={isStreaming} onSelectionTextChange={onSelectionTextChange} + projectId={projectId} + contentId={contentId} + autoImageTopic={autoImageTopic} /> ); } @@ -100,6 +112,8 @@ export const CanvasFactory: React.FC = memo( onStateChange={onStateChange as (s: PosterCanvasState) => void} onBackHome={resolvedBackHome} onClose={onClose} + projectId={projectId} + contentId={contentId} /> ); } @@ -112,6 +126,8 @@ export const CanvasFactory: React.FC = memo( onBackHome={resolvedBackHome} onClose={onClose} isStreaming={isStreaming} + projectId={projectId} + contentId={contentId} /> ); } @@ -123,6 +139,8 @@ export const CanvasFactory: React.FC = memo( onStateChange={onStateChange as (s: ScriptCanvasState) => void} onBackHome={resolvedBackHome} onClose={onClose} + projectId={projectId} + contentId={contentId} /> ); } @@ -134,6 +152,8 @@ export const CanvasFactory: React.FC = 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 = memo( onStateChange={onStateChange as (s: VideoCanvasState) => void} onBackHome={resolvedBackHome} onClose={onClose} + projectId={projectId} + contentId={contentId} /> ); } diff --git a/src/components/content-creator/canvas/canvasUtils.test.ts b/src/components/content-creator/canvas/canvasUtils.test.ts index 85f273179..a751cba16 100644 --- a/src/components/content-creator/canvas/canvasUtils.test.ts +++ b/src/components/content-creator/canvas/canvasUtils.test.ts @@ -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(); diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index 99c28eed6..258f8584a 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -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 = 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 = 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 => { + if (!query.trim()) { + return null; + } + + try { + const webResp = await invoke("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( + "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 = memo( onSave={handleSave} onCancel={handleCancel} onExport={handleExport} + onAutoInsertImages={handleAutoInsertImages} + autoInsertLoading={autoInsertLoading} onClose={onClose} /> @@ -219,6 +523,33 @@ export const DocumentCanvas: React.FC = 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, + ); + }} /> ) : ( = memo( onSave, onCancel, onExport, + onAutoInsertImages, + autoInsertLoading = false, onClose, }) => { const [showExportMenu, setShowExportMenu] = useState(false); @@ -191,6 +193,15 @@ export const DocumentToolbar: React.FC = memo( ) : ( <> ✏️ 编辑 + {onAutoInsertImages && ( + + {autoInsertLoading ? "🖼️ 配图中..." : "🖼️ 主题配图"} + + )} setShowExportMenu(!showExportMenu)}> 📤 导出 ▼ diff --git a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx index 28bee3e59..44ec07581 100644 --- a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx +++ b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx @@ -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 = memo( - ({ content, onChange, onSave, onCancel, onSelectionTextChange }) => { + ({ + content, + onChange, + onSave, + onCancel, + onSelectionTextChange, + externalImageInsert, + onExternalImageInsertComplete, + }) => { const [slashState, setSlashState] = useState(EMPTY_SLASH); const keyDownRef = useRef(null); + const handledExternalInsertRef = useRef(null); const extensions = useMemo( () => @@ -113,6 +128,31 @@ export const NotionEditor: React.FC = 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 ( diff --git a/src/components/content-creator/canvas/document/types.ts b/src/components/content-creator/canvas/document/types.ts index ebe10312e..471e0b5b6 100644 --- a/src/components/content-creator/canvas/document/types.ts +++ b/src/components/content-creator/canvas/document/types.ts @@ -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; } diff --git a/src/components/content-creator/canvas/document/utils/autoImageInsert.test.ts b/src/components/content-creator/canvas/document/utils/autoImageInsert.test.ts new file mode 100644 index 000000000..0988c8a69 --- /dev/null +++ b/src/components/content-creator/canvas/document/utils/autoImageInsert.test.ts @@ -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("![交通图](https://img.example/traffic.jpg)"); + 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("交通"); + }); +}); + diff --git a/src/components/content-creator/canvas/document/utils/autoImageInsert.ts b/src/components/content-creator/canvas/document/utils/autoImageInsert.ts new file mode 100644 index 000000000..e4ce4fbd4 --- /dev/null +++ b/src/components/content-creator/canvas/document/utils/autoImageInsert.ts @@ -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 = [`![${altText}](${image.contentUrl})`]; + + 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(); + + 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} 高清摄影`; +} + diff --git a/src/components/content-creator/canvas/music/MusicCanvas.tsx b/src/components/content-creator/canvas/music/MusicCanvas.tsx index 8c66765b0..6cdaa473f 100644 --- a/src/components/content-creator/canvas/music/MusicCanvas.tsx +++ b/src/components/content-creator/canvas/music/MusicCanvas.tsx @@ -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 = { @@ -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 = 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 = 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 = `![${alt}](${imageUrl})`; + 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 = memo( handleSectionSelect(section.id)} > diff --git a/src/components/content-creator/canvas/music/types.ts b/src/components/content-creator/canvas/music/types.ts index f767bc0ab..a24b98efc 100644 --- a/src/components/content-creator/canvas/music/types.ts +++ b/src/components/content-creator/canvas/music/types.ts @@ -451,6 +451,10 @@ export interface MusicCanvasProps { state: MusicCanvasState; /** 状态变更回调 */ onStateChange: (state: MusicCanvasState) => void; + /** 当前项目 ID(用于跨页插图) */ + projectId?: string | null; + /** 当前内容 ID(用于跨页插图) */ + contentId?: string | null; /** 返回首页回调 */ onBackHome?: () => void; /** 关闭画布回调 */ diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx index 117750b14..90931ccda 100644 --- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx +++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx @@ -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 = `![${alt}](${imageUrl})`; + const trimmed = content.trimEnd(); + if (!trimmed) { + return snippet; + } + return `${trimmed}\n\n${snippet}\n`; +} + export const NovelCanvas: React.FC = memo( ({ state, onStateChange, + projectId, + contentId, onBackHome, onClose, useExternalToolbar = false, @@ -240,6 +285,9 @@ export const NovelCanvas: React.FC = 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 = 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 = memo( handleChapterSelect(chapter.id)} > diff --git a/src/components/content-creator/canvas/poster/PosterCanvas.tsx b/src/components/content-creator/canvas/poster/PosterCanvas.tsx index 9363fb5ac..16422bdf2 100644 --- a/src/components/content-creator/canvas/poster/PosterCanvas.tsx +++ b/src/components/content-creator/canvas/poster/PosterCanvas.tsx @@ -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 = memo( - ({ state, onStateChange, onBackHome, onClose }) => { + ({ state, onStateChange, projectId, contentId, onBackHome, onClose }) => { const canvasRef = useRef(null); const wrapperRef = useRef(null); const fileInputRef = useRef(null); @@ -157,7 +166,7 @@ export const PosterCanvas: React.FC = 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 = 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 ( diff --git a/src/components/content-creator/canvas/poster/types.ts b/src/components/content-creator/canvas/poster/types.ts index 0e3ea2d67..e09c5018f 100644 --- a/src/components/content-creator/canvas/poster/types.ts +++ b/src/components/content-creator/canvas/poster/types.ts @@ -168,6 +168,10 @@ export interface PosterCanvasProps { state: PosterCanvasState; /** 状态变更回调 */ onStateChange: (state: PosterCanvasState) => void; + /** 当前项目 ID(用于跨页插图) */ + projectId?: string | null; + /** 当前内容 ID(用于跨页插图) */ + contentId?: string | null; /** 返回首页回调 */ onBackHome?: () => void; /** 关闭画布回调 */ diff --git a/src/components/content-creator/canvas/script/ScriptCanvas.tsx b/src/components/content-creator/canvas/script/ScriptCanvas.tsx index de874938e..a6663fdff 100644 --- a/src/components/content-creator/canvas/script/ScriptCanvas.tsx +++ b/src/components/content-creator/canvas/script/ScriptCanvas.tsx @@ -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 = `![${alt}](${imageUrl})`; + const trimmed = text.trimEnd(); + return trimmed ? `${trimmed}\n\n${snippet}` : snippet; +} + export const ScriptCanvas: React.FC = 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 = 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 ( @@ -238,6 +374,7 @@ export const ScriptCanvas: React.FC = memo( handleSceneSelect(scene.id)} > 第{scene.number}场 diff --git a/src/components/content-creator/canvas/video/VideoCanvas.tsx b/src/components/content-creator/canvas/video/VideoCanvas.tsx index d6a3a9cc3..21fa91971 100644 --- a/src/components/content-creator/canvas/video/VideoCanvas.tsx +++ b/src/components/content-creator/canvas/video/VideoCanvas.tsx @@ -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 = { doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"], @@ -198,7 +207,14 @@ const MainAction = styled.button` `; export const VideoCanvas: React.FC = 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([]); @@ -280,6 +296,58 @@ export const VideoCanvas: React.FC = 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 ( diff --git a/src/components/content-creator/canvas/video/types.ts b/src/components/content-creator/canvas/video/types.ts index d7e27cdb9..68a264617 100644 --- a/src/components/content-creator/canvas/video/types.ts +++ b/src/components/content-creator/canvas/video/types.ts @@ -31,6 +31,7 @@ export interface VideoCanvasProps { state: VideoCanvasState; onStateChange: (state: VideoCanvasState) => void; projectId?: string | null; + contentId?: string | null; onClose?: () => void; onBackHome?: () => void; } diff --git a/src/components/general-chat/chat/ErrorBoundary.tsx b/src/components/general-chat/chat/ErrorBoundary.tsx index c3c7539c8..0e09b48aa 100644 --- a/src/components/general-chat/chat/ErrorBoundary.tsx +++ b/src/components/general-chat/chat/ErrorBoundary.tsx @@ -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, + }); } /** diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx index 31b100b40..1d77071e9 100644 --- a/src/components/image-gen/ImageGenPage.tsx +++ b/src/components/image-gen/ImageGenPage.tsx @@ -1,227 +1,32 @@ /** * @file 图片生成页面 - * @description 对齐成熟产品风格的绘画工作台布局与交互 + * @description 插图功能 - 包含图片搜索、AI生图、本地图片、我的图片库四个 Tab * @module components/image-gen/ImageGenPage */ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import styled from "styled-components"; -import { - Image as ImageIcon, - ImagePlus, - Loader2, - Plus, - Send, - Settings, - Sparkles, - Trash2, - ExternalLink, - X, -} from "lucide-react"; -import { toast } from "sonner"; -import { useImageGen } from "./useImageGen"; -import type { GeneratedImage } from "./types"; +import { useState, useEffect, useMemo } from "react"; +import styled, { keyframes } from "styled-components"; +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; import { useProjects } from "@/hooks/useProjects"; import { getStoredResourceProjectId, onResourceProjectChange, setStoredResourceProjectId, } from "@/lib/resourceProjectSelection"; -import type { Page } from "@/types/page"; -import { CharacterMention } from "@/components/agent/chat/components/Inputbar/components/CharacterMention"; -import { SkillBadge } from "@/components/agent/chat/components/Inputbar/components/SkillBadge"; -import { useActiveSkill } from "@/components/agent/chat/components/Inputbar/hooks/useActiveSkill"; -import { skillsApi, type Skill } from "@/lib/api/skills"; -import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; +import type { Page, PageParams } from "@/types/page"; +import { ChevronDown } from "lucide-react"; + +type PageNavigate = (page: Page, params?: PageParams) => void; +import { AiImageGenTab } from "./tabs/AiImageGenTab"; +import { ImageSearchTab } from "./tabs/ImageSearchTab"; +import { LocalImageTab } from "./tabs/LocalImageTab"; +import { MyGalleryTab } from "./tabs/MyGalleryTab"; interface ImageGenPageProps { - onNavigate?: (page: Page) => void; + onNavigate?: PageNavigate; } -type ResolutionPreset = "1k" | "2k" | "4k"; - -interface ReferenceImageItem { - id: string; - name: string; - url: string; -} - -const RESOLUTION_OPTIONS: Array<{ - label: string; - value: ResolutionPreset; - longEdge: number; -}> = [ - { label: "1K", value: "1k", longEdge: 1024 }, - { label: "2K", value: "2k", longEdge: 2048 }, - { label: "4K", value: "4k", longEdge: 4096 }, -]; - -const ASPECT_RATIO_OPTIONS = [ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "9:16", - "5:4", - "4:5", - "16:9", - "21:9", -]; - -const IMAGE_COUNT_PRESETS = [1, 2, 4, 8]; - -const FALLBACK_SUPPORTED_SIZES = [ - "1024x1024", - "768x1344", - "864x1152", - "1344x768", - "1152x864", -]; - -function parseSize(size: string): { width: number; height: number } | null { - const [rawWidth, rawHeight] = size.split("x"); - const width = Number(rawWidth); - const height = Number(rawHeight); - - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return { width, height }; -} - -function parseAspectRatio(ratio: string): number { - const [rawWidth, rawHeight] = ratio.split(":"); - const width = Number(rawWidth); - const height = Number(rawHeight); - - if ( - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return 1; - } - - return width / height; -} - -function chooseClosestSize( - supportedSizes: string[], - aspectRatio: string, - resolutionPreset: ResolutionPreset, -): string { - const candidates = supportedSizes - .map((size) => ({ - raw: size, - parsed: parseSize(size), - })) - .filter( - ( - candidate, - ): candidate is { - raw: string; - parsed: { width: number; height: number }; - } => candidate.parsed !== null, - ); - - if (candidates.length === 0) { - return FALLBACK_SUPPORTED_SIZES[0]; - } - - const ratioValue = parseAspectRatio(aspectRatio); - const longEdge = - RESOLUTION_OPTIONS.find((option) => option.value === resolutionPreset) - ?.longEdge || 1024; - - const targetWidth = - ratioValue >= 1 ? longEdge : Math.max(1, Math.round(longEdge * ratioValue)); - const targetHeight = - ratioValue >= 1 ? Math.max(1, Math.round(longEdge / ratioValue)) : longEdge; - - const targetArea = targetWidth * targetHeight; - - const best = candidates.reduce( - (current, candidate) => { - const candidateRatio = candidate.parsed.width / candidate.parsed.height; - const candidateArea = candidate.parsed.width * candidate.parsed.height; - - const ratioScore = Math.abs(Math.log(candidateRatio / ratioValue)); - const areaScore = Math.abs(candidateArea - targetArea) / targetArea; - const totalScore = ratioScore * 3 + areaScore; - - if (totalScore < current.score) { - return { score: totalScore, size: candidate.raw }; - } - - return current; - }, - { score: Number.POSITIVE_INFINITY, size: candidates[0].raw }, - ); - - return best.size; -} - -function fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - - reject(new Error("文件读取失败")); - }; - - reader.onerror = () => reject(new Error("文件读取失败")); - reader.readAsDataURL(file); - }); -} - -function resolveBatchImages( - images: GeneratedImage[], - selectedImageId: string | null, -): GeneratedImage[] { - if (!selectedImageId) { - return []; - } - - const batchMatch = selectedImageId.match(/^img-(\d+)-\d+$/); - if (!batchMatch) { - const single = images.find((item) => item.id === selectedImageId); - return single ? [single] : []; - } - - const batchPrefix = `img-${batchMatch[1]}-`; - return images - .filter((item) => item.id.startsWith(batchPrefix)) - .sort((left, right) => left.createdAt - right.createdAt); -} - -function getStatusText(status: GeneratedImage["status"]): string { - switch (status) { - case "complete": - return "已完成"; - case "error": - return "失败"; - case "generating": - return "生成中"; - default: - return "待生成"; - } -} - -const Container = styled.div` - flex: 1; - min-height: 0; - display: flex; - background: hsl(var(--background)); - color: hsl(var(--foreground)); -`; +// ==================== Styled Components ==================== const PageLayout = styled.div` display: flex; @@ -231,652 +36,150 @@ const PageLayout = styled.div` `; const HeaderBar = styled.div` - display: flex; - align-items: center; - padding: 10px 16px 6px; - background: hsl(var(--background)); -`; - -const ControlPanel = styled.aside` - width: 280px; - min-width: 280px; - padding: 16px 12px; - border-right: 1px solid hsl(var(--border)); - background: hsl(var(--card) / 0.4); - overflow-y: auto; -`; - -const Section = styled.section` - margin-bottom: 18px; -`; - -const SectionTitle = styled.div` display: flex; align-items: center; justify-content: space-between; - margin-bottom: 8px; - font-size: 14px; - font-weight: 600; - color: hsl(var(--foreground)); -`; - -const Hint = styled.div` - margin-top: 6px; - font-size: 12px; - color: hsl(var(--muted-foreground)); -`; - -const Select = styled.select` - width: 100%; - height: 38px; - border: 1px solid hsl(var(--border)); - border-radius: 10px; + padding: 10px 20px 6px; background: hsl(var(--background)); - padding: 0 10px; - font-size: 14px; - color: hsl(var(--foreground)); - - &:focus { - outline: none; - border-color: hsl(var(--primary)); - } + border-bottom: 1px solid hsl(var(--border) / 0.5); `; -const FullButton = styled.button<{ $disabled?: boolean }>` - width: 100%; - height: 34px; - border-radius: 10px; - border: 1px solid hsl(var(--border)); - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-size: 13px; - cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; - opacity: ${({ $disabled }) => ($disabled ? 0.65 : 1)}; - - &:hover { - border-color: ${({ $disabled }) => - $disabled ? "hsl(var(--border))" : "hsl(var(--primary) / 0.4)"}; - background: ${({ $disabled }) => - $disabled ? "hsl(var(--background))" : "hsl(var(--accent) / 0.4)"}; - } -`; - -const SmallButton = styled.button` - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: 1px solid hsl(var(--border)); - border-radius: 8px; - background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); - cursor: pointer; - - &:hover { - color: hsl(var(--foreground)); - border-color: hsl(var(--primary) / 0.4); - } -`; - -const UploadBox = styled.div<{ $dragging: boolean }>` - border: 1px dashed - ${({ $dragging }) => - $dragging ? "hsl(var(--primary))" : "hsl(var(--border))"}; - border-radius: 12px; - min-height: 108px; - background: ${({ $dragging }) => - $dragging ? "hsl(var(--primary) / 0.06)" : "hsl(var(--muted) / 0.2)"}; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 10px; - cursor: pointer; -`; - -const UploadText = styled.div` - font-size: 12px; - line-height: 1.5; - color: hsl(var(--muted-foreground)); -`; - -const Thumbs = styled.div` - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -`; - -const ThumbItem = styled.div` +const ProjectSelectorWrapper = styled.div` position: relative; - border-radius: 8px; - overflow: hidden; - border: 1px solid hsl(var(--border)); - aspect-ratio: 1; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -`; - -const RemoveThumb = styled.button` - position: absolute; - top: 4px; - right: 4px; - width: 18px; - height: 18px; - border: none; - border-radius: 999px; - background: hsl(var(--background) / 0.9); - color: hsl(var(--destructive)); display: inline-flex; align-items: center; - justify-content: center; - cursor: pointer; `; -const Segment = styled.div` - display: flex; - gap: 6px; -`; - -const SegmentButton = styled.button<{ $active: boolean }>` - flex: 1; - height: 30px; - border-radius: 8px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "transparent")}; - background: ${({ $active }) => - $active ? "hsl(var(--background))" : "hsl(var(--muted) / 0.35)"}; - color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; - font-size: 13px; - cursor: pointer; -`; - -const RatioGrid = styled.div` - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 8px; -`; - -const RatioButton = styled.button<{ $active: boolean }>` - height: 44px; - border-radius: 8px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; - background: ${({ $active }) => - $active ? "hsl(var(--primary) / 0.08)" : "hsl(var(--background))"}; - color: ${({ $active }) => - $active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; - font-size: 12px; - font-weight: ${({ $active }) => ($active ? 600 : 500)}; - cursor: pointer; -`; - -const CountRow = styled.div` - display: flex; - gap: 6px; -`; - -const CountButton = styled.button<{ $active: boolean }>` - flex: 1; - height: 30px; - border-radius: 8px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "transparent")}; - background: ${({ $active }) => - $active ? "hsl(var(--background))" : "hsl(var(--muted) / 0.35)"}; - color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; - font-size: 13px; - cursor: pointer; -`; - -const CountInput = styled.input` - width: 100%; - height: 32px; +const ProjectSelector = styled.select` + appearance: none; + padding: 6px 32px 6px 14px; border: 1px solid hsl(var(--border)); - border-radius: 8px; - background: hsl(var(--background)); - padding: 0 10px; + border-radius: 10px; + background: hsl(var(--card) / 0.6); font-size: 13px; + color: hsl(var(--foreground)); + cursor: pointer; + transition: all 0.2s ease; + backdrop-filter: blur(8px); + + &:hover { + border-color: hsl(var(--primary) / 0.5); + background: hsl(var(--card) / 0.9); + } &:focus { outline: none; border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); } `; -const Workspace = styled.main` +const SelectorIcon = styled.div` + position: absolute; + right: 10px; + pointer-events: none; + color: hsl(var(--muted-foreground)); +`; + +const MainContainer = styled.div` flex: 1; display: flex; flex-direction: column; min-height: 0; - min-width: 0; - padding: 14px; + background: hsl(var(--background)); `; -const Canvas = styled.div` - flex: 1; - border: 1px solid hsl(var(--border)); - border-radius: 10px; - background: hsl(var(--background)); +const TabsBar = styled.div` display: flex; align-items: center; - justify-content: center; + gap: 0; + padding: 0 20px; + background: hsl(var(--background)); + border-bottom: 1px solid hsl(var(--border) / 0.4); +`; + +const slideIn = keyframes` + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +`; + +const TabButton = styled.button<{ $active: boolean }>` position: relative; - overflow: hidden; -`; - -const Empty = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - color: hsl(var(--muted-foreground)); - - h2 { - margin: 0; - font-size: 48px; - font-weight: 700; - letter-spacing: 2px; - color: hsl(var(--foreground)); - } -`; - -const PreviewImage = styled.img` - max-width: 100%; - max-height: 100%; - object-fit: contain; -`; - -const BatchGrid = styled.div` - width: 100%; - height: 100%; - padding: 12px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - align-content: start; - overflow: auto; -`; - -const BatchItem = styled.button<{ $active: boolean }>` - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; - border-radius: 10px; - background: hsl(var(--background)); - cursor: pointer; - display: flex; - flex-direction: column; - padding: 8px; - gap: 8px; -`; - -const BatchPreviewWrap = styled.div` - border-radius: 8px; - background: hsl(var(--muted) / 0.25); - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - - img { - width: 100%; - height: 100%; - object-fit: contain; - } -`; - -const BatchPlaceholder = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 8px; - color: hsl(var(--muted-foreground)); -`; - -const BatchMeta = styled.div` - display: flex; - justify-content: space-between; - gap: 8px; - font-size: 12px; - color: hsl(var(--muted-foreground)); -`; - -const CanvasActions = styled.div` - position: absolute; - top: 12px; - right: 12px; - display: flex; - gap: 8px; -`; - -const CanvasActionButton = styled.button` - width: 32px; - height: 32px; - border-radius: 8px; - border: 1px solid hsl(var(--border)); - background: hsl(var(--background) / 0.92); - display: inline-flex; - align-items: center; - justify-content: center; - color: hsl(var(--muted-foreground)); - cursor: pointer; - - &:hover { - color: hsl(var(--foreground)); - } -`; - -const PromptDock = styled.div` - width: 78%; - max-width: 860px; - min-width: 520px; - margin: 12px auto 0; - background: hsl(var(--background)); - border: 1px solid hsl(var(--border)); - border-radius: 14px; - padding: 10px; - display: flex; - align-items: flex-end; - gap: 10px; - - @media (max-width: 1100px) { - width: 90%; - min-width: 0; - } -`; - -const PromptInput = styled.textarea` - flex: 1; - min-height: 44px; - max-height: 140px; + padding: 12px 24px; border: none; - resize: none; background: transparent; font-size: 14px; - line-height: 1.5; - color: hsl(var(--foreground)); - padding: 8px; - font-family: inherit; - - &:focus { - outline: none; - } - - &::placeholder { - color: hsl(var(--muted-foreground)); - } -`; - -const GenerateButton = styled.button<{ $disabled: boolean }>` - width: 52px; - height: 52px; - border: none; - border-radius: 12px; - background: ${({ $disabled }) => - $disabled ? "hsl(var(--muted))" : "hsl(var(--primary))"}; - color: ${({ $disabled }) => - $disabled - ? "hsl(var(--muted-foreground))" - : "hsl(var(--primary-foreground))"}; - cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; - display: inline-flex; - align-items: center; - justify-content: center; -`; - -const PromptHistoryDock = styled.div` - width: 78%; - max-width: 860px; - min-width: 520px; - margin: 8px auto 0; - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - - @media (max-width: 1100px) { - width: 90%; - min-width: 0; - } -`; - -const PromptHistoryLabel = styled.div` - color: hsl(var(--muted-foreground)); - white-space: nowrap; -`; - -const PromptHistoryChip = styled.button<{ $active: boolean }>` - flex: 1; - max-width: 100%; - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; - border-radius: 999px; - background: ${({ $active }) => - $active ? "hsl(var(--primary) / 0.14)" : "hsl(var(--muted) / 0.35)"}; + font-weight: ${({ $active }) => ($active ? 600 : 400)}; color: ${({ $active }) => $active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; - padding: 4px 10px; - font-size: 12px; - line-height: 1.4; cursor: pointer; + transition: all 0.2s ease; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; &:hover { - border-color: hsl(var(--primary)); - color: hsl(var(--primary)); + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--foreground))"}; + } + + &::after { + content: ""; + position: absolute; + bottom: -1px; + left: 12px; + right: 12px; + height: 2px; + border-radius: 2px 2px 0 0; + background: ${({ $active }) => + $active ? "hsl(var(--primary))" : "transparent"}; + transition: background 0.2s ease; + animation: ${({ $active }) => ($active ? slideIn : "none")} 0.25s ease; } `; -const Status = styled.div` - margin-top: 8px; - font-size: 12px; - color: hsl(var(--muted-foreground)); -`; - -const HistorySidebar = styled.aside` - width: 96px; - min-width: 96px; - border-left: 1px solid hsl(var(--border)); - background: hsl(var(--card) / 0.3); - padding: 12px 8px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -const HistoryNewButton = styled.button` - width: 100%; - height: 40px; - border: 1px dashed hsl(var(--border)); - border-radius: 10px; - background: transparent; - color: hsl(var(--muted-foreground)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - - &:hover { - border-color: hsl(var(--primary)); - color: hsl(var(--primary)); - background: hsl(var(--primary) / 0.06); - } -`; - -const HistoryList = styled.div` +const TabContent = styled.div` flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; - padding-right: 2px; -`; - -const HistoryItem = styled.div<{ $active: boolean }>` - width: 100%; - aspect-ratio: 1; - border-radius: 10px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; - background: hsl(var(--background)); + min-height: 0; overflow: hidden; - cursor: pointer; - position: relative; - - &:hover { - border-color: hsl(var(--primary) / 0.55); - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } `; -const HistoryPlaceholder = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: hsl(var(--muted-foreground)); -`; +// ==================== Component ==================== -const HistoryDeleteButton = styled.button` - position: absolute; - top: 4px; - right: 4px; - width: 20px; - height: 20px; - border: 1px solid hsl(var(--destructive) / 0.35); - border-radius: 50%; - background: hsl(var(--background) / 0.92); - color: hsl(var(--destructive)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0; - transition: all 0.15s; - - ${HistoryItem}:hover & { - opacity: 1; - } - - &:hover { - background: hsl(var(--destructive)); - color: hsl(var(--destructive-foreground)); - } -`; - -const HistoryEmpty = styled.div` - margin-top: 10px; - font-size: 12px; - color: hsl(var(--muted-foreground)); - text-align: center; -`; +const TABS = [ + { key: "ai-gen", label: "AI生图" }, + { key: "search", label: "图片搜索" }, + { key: "local", label: "本地图片" }, + { key: "gallery", label: "我的图片库" }, +] as const; export function ImageGenPage({ onNavigate }: ImageGenPageProps) { - const { - availableProviders, - selectedProvider, - selectedProviderId, - setSelectedProviderId, - providersLoading, - availableModels, - selectedModel, - selectedModelId, - setSelectedModelId, - selectedSize, - setSelectedSize, - images, - selectedImage, - selectedImageId, - setSelectedImageId, - generating, - savingToResource, - generateImage, - backfillImagesToResource, - deleteImage, - newImage, - } = useImageGen(); + const [activeTab, setActiveTab] = useState("ai-gen"); + const [selectedProjectId, setSelectedProjectId] = useState(null); const { projects, defaultProject, loading: projectsLoading } = useProjects(); - const [prompt, setPrompt] = useState(""); - const [skills, setSkills] = useState([]); - const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = - useActiveSkill(); - const promptRef = useRef(null); - const [resolutionPreset, setResolutionPreset] = - useState("1k"); - const [aspectRatio, setAspectRatio] = useState("1:1"); - const [imageCount, setImageCount] = useState(1); - const [isEditingCustomCount, setIsEditingCustomCount] = useState(false); - const [customCountInput, setCustomCountInput] = useState(""); - const [referenceImages, setReferenceImages] = useState( - [], - ); - const [isDraggingUpload, setIsDraggingUpload] = useState(false); - const [targetProjectId, setTargetProjectId] = useState(""); - - const fileInputRef = useRef(null); - - // 加载技能列表 - useEffect(() => { - skillsApi - .getAll("proxycast") - .then(setSkills) - .catch((err) => console.error("加载技能列表失败:", err)); - }, []); - + // 可用项目列表(排除已归档) const availableProjects = useMemo( () => projects.filter((project) => !project.isArchived), [projects], ); - const selectedTargetProject = useMemo( - () => availableProjects.find((project) => project.id === targetProjectId), - [availableProjects, targetProjectId], - ); - - const supportedSizes = useMemo(() => { - return selectedModel?.supportedSizes || FALLBACK_SUPPORTED_SIZES; - }, [selectedModel]); - - const resolvedSize = useMemo(() => { - return chooseClosestSize(supportedSizes, aspectRatio, resolutionPreset); - }, [supportedSizes, aspectRatio, resolutionPreset]); - - useEffect(() => { - if (resolvedSize !== selectedSize) { - setSelectedSize(resolvedSize); - } - }, [resolvedSize, selectedSize, setSelectedSize]); - + // 初始化项目 ID useEffect(() => { if (projectsLoading) { return; } - setTargetProjectId((current) => { + setSelectedProjectId((current) => { + // 如果当前值有效,保持不变 if (current && availableProjects.some((project) => project.id === current)) { return current; } + // 从存储中获取 const storedProjectId = getStoredResourceProjectId({ includeLegacy: true }); if ( storedProjectId && @@ -885,22 +188,27 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { return storedProjectId; } + // 使用默认项目 const preferredProject = (defaultProject && !defaultProject.isArchived ? defaultProject : null) ?? availableProjects[0]; - return preferredProject?.id || ""; + return preferredProject?.id || null; }); }, [projectsLoading, availableProjects, defaultProject]); + // 同步项目 ID 到存储 useEffect(() => { - setStoredResourceProjectId(targetProjectId, { - source: "image-gen-target", - syncLegacy: true, - emitEvent: true, - }); - }, [targetProjectId]); + if (selectedProjectId) { + setStoredResourceProjectId(selectedProjectId, { + source: "image-gen-target", + syncLegacy: true, + emitEvent: true, + }); + } + }, [selectedProjectId]); + // 监听项目变更事件 useEffect(() => { return onResourceProjectChange((detail) => { if (detail.source !== "resources") { @@ -908,7 +216,7 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { } const nextProjectId = detail.projectId; - if (!nextProjectId || nextProjectId === targetProjectId) { + if (!nextProjectId || nextProjectId === selectedProjectId) { return; } @@ -916,126 +224,9 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { return; } - setTargetProjectId(nextProjectId); + setSelectedProjectId(nextProjectId); }); - }, [availableProjects, targetProjectId]); - - const canGenerate = - !!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating; - - const selectedBatchImages = useMemo(() => { - return resolveBatchImages(images, selectedImageId); - }, [images, selectedImageId]); - - const selectedPromptHistory = useMemo(() => { - return selectedImage?.prompt.trim() || ""; - }, [selectedImage]); - - const isFalProvider = - selectedProvider?.id === "fal" || selectedProvider?.type === "fal"; - - const shouldShowBatchGrid = selectedBatchImages.length > 1; - - const handleCountSelect = (count: number) => { - setImageCount(count); - setIsEditingCustomCount(false); - }; - - const handleCustomCountConfirm = () => { - const next = Number(customCountInput); - if (!Number.isFinite(next)) return; - const normalized = Math.max(1, Math.min(8, Math.floor(next))); - setImageCount(normalized); - setIsEditingCustomCount(false); - setCustomCountInput(""); - }; - - const handleReferenceFiles = async (files: FileList | null) => { - if (!files || files.length === 0) return; - - const remain = Math.max(0, 3 - referenceImages.length); - if (remain === 0) return; - - const selectedFiles = Array.from(files) - .filter((file) => file.type.startsWith("image/")) - .slice(0, remain); - - if (selectedFiles.length === 0) return; - - const loaded = await Promise.all( - selectedFiles.map(async (file) => ({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: file.name, - url: await fileToDataUrl(file), - })), - ); - - setReferenceImages((prev) => [...prev, ...loaded].slice(0, 3)); - }; - - const handleUploadChange = async ( - event: React.ChangeEvent, - ) => { - await handleReferenceFiles(event.target.files); - event.target.value = ""; - }; - - const handleGenerate = async () => { - if (!canGenerate) return; - - const finalPrompt = activeSkill - ? wrapTextWithSkill(prompt.trim()) - : prompt.trim(); - - try { - await generateImage(finalPrompt, { - imageCount, - referenceImages: referenceImages.map((item) => item.url), - size: resolvedSize, - targetProjectId: targetProjectId || undefined, - }); - setPrompt(""); - clearActiveSkill(); - } catch (error) { - console.error("图片生成失败:", error); - } - }; - - const handleBackfillToResource = async () => { - if (!targetProjectId) { - toast.error("请先选择目标资源库"); - return; - } - - try { - const result = await backfillImagesToResource(targetProjectId); - if (result.failed > 0) { - toast.error(`补录完成:成功 ${result.saved},失败 ${result.failed}`); - } else { - toast.success(`补录完成:新增 ${result.saved},跳过 ${result.skipped}`); - } - - if (result.errors.length > 0) { - console.warn("[ImageGen] 历史补录失败详情:", result.errors); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`补录失败: ${message}`); - } - }; - - const handlePromptKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleGenerate(); - } - }; - - const goCredentialManagement = () => { - onNavigate?.("provider-pool"); - }; + }, [availableProjects, selectedProjectId]); const goHome = () => { onNavigate?.("agent"); @@ -1044,440 +235,60 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { return ( - + + + setSelectedProjectId(e.target.value || null)} + disabled={projectsLoading} + > + + {availableProjects.map((project) => ( + + ))} + + + + + - - - {availableProviders.length > 1 && ( -
- 服务商 - -
- )} - -
- - 模型 - - - - - - 当前服务商:{selectedProvider?.name || "未选择"} -
+ {tab.label} + + ))} + -
- 目标资源库 - - - {targetProjectId - ? `生成成功后会自动写入「${selectedTargetProject?.name || "已选项目"}」资源库` - : "未启用自动入库,生成结果仅保存在当前页面历史"} - - { - void handleBackfillToResource(); - }} - $disabled={savingToResource || !targetProjectId || images.length === 0} - disabled={savingToResource || !targetProjectId || images.length === 0} - > - {savingToResource ? "补录中..." : "补录历史到资源库"} - -
- -
- 参考图 - {referenceImages.length > 0 ? ( - - {referenceImages.map((item) => ( - - {item.name} - { - setReferenceImages((prev) => - prev.filter((current) => current.id !== item.id), - ); - }} - > - - - - ))} - - ) : ( - fileInputRef.current?.click()} - onDragOver={(event) => { - event.preventDefault(); - setIsDraggingUpload(true); - }} - onDragLeave={(event) => { - event.preventDefault(); - setIsDraggingUpload(false); - }} - onDrop={async (event) => { - event.preventDefault(); - setIsDraggingUpload(false); - await handleReferenceFiles(event.dataTransfer.files); - }} - > - - -
点击或拖拽上传图片
-
支持最多 3 张图片
-
-
- )} - + {activeTab === "search" && ( + - - {isFalProvider - ? "Fal 上传参考图会启用图片编辑参数;Nano Banana 会优先尝试 /edit 接口。" - : "上传参考图会随请求发送给模型,是否执行编辑由模型能力决定。"} - -
- -
- 分辨率 - - {RESOLUTION_OPTIONS.map((option) => ( - setResolutionPreset(option.value)} - > - {option.label} - - ))} - -
- -
- 宽高比 - - {ASPECT_RATIO_OPTIONS.map((ratio) => ( - setAspectRatio(ratio)} - > - {ratio} - - ))} - -
- -
- 图片数量 - {isEditingCustomCount ? ( - setCustomCountInput(event.target.value)} - onBlur={handleCustomCountConfirm} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleCustomCountConfirm(); - } - }} - autoFocus - /> - ) : ( - - {IMAGE_COUNT_PRESETS.map((count) => ( - handleCountSelect(count)} - > - {count} - - ))} - { - setCustomCountInput(String(imageCount)); - setIsEditingCustomCount(true); - }} - > - + - - - )} -
- - 实际输出尺寸:{resolvedSize} - {selectedImage?.status === "complete" && targetProjectId && ( - - {selectedImage.resourceMaterialId && - selectedImage.resourceProjectId === targetProjectId - ? "当前图片已同步到资源库" - : selectedImage.resourceSaveError - ? `当前图片入库失败:${selectedImage.resourceSaveError}` - : savingToResource - ? "当前图片正在同步到资源库..." - : "当前图片尚未同步到资源库"} - )} -
- - - - {shouldShowBatchGrid ? ( - - {selectedBatchImages.map((item, index) => { - const parsedSize = parseSize(item.size); - const previewStyle = parsedSize - ? { - aspectRatio: `${parsedSize.width}/${parsedSize.height}`, - } - : undefined; - - return ( - setSelectedImageId(item.id)} - > - - {item.status === "complete" && item.url ? ( - {item.prompt - ) : ( - - {item.status === "error" ? ( - - ) : ( - - )} - {getStatusText(item.status)} - - )} - - - - 第 {index + 1} 张 - {getStatusText(item.status)} - - - ); - })} - - ) : selectedImage?.status === "complete" && selectedImage.url ? ( - <> - - - window.open(selectedImage.url, "_blank")} - > - - - deleteImage(selectedImage.id)} - > - - - - - ) : selectedImage?.status === "error" ? ( - - -

生成失败

-
{selectedImage.error || "请重试"}
-
- ) : ( - - {generating || selectedImage?.status === "generating" ? ( - - ) : ( - - )} -

绘画

-
- )} - - {shouldShowBatchGrid && - selectedImage?.status === "complete" && - selectedImage.url && ( - - window.open(selectedImage.url, "_blank")} - > - - - deleteImage(selectedImage.id)} - > - - - - )} -
- - - {/* CharacterMention */} - {skills.length > 0 && ( - - )} - {/* Skill Badge */} - {activeSkill && ( - - )} - setPrompt(event.target.value)} - onKeyDown={handlePromptKeyDown} - placeholder="描述你想要生成的内容" - disabled={!selectedProvider || !selectedModelId || generating} + {activeTab === "ai-gen" && ( + + )} + {activeTab === "local" && ( + + )} + {activeTab === "gallery" && ( + - - {generating ? ( - - ) : ( - - )} - - - {selectedPromptHistory && ( - - 当前图片提示词 - setPrompt(selectedPromptHistory)} - > - {selectedPromptHistory} - - )} - - {!selectedProvider && ( - - 当前没有可用绘画服务,请先到凭证管理添加可用 Provider。 - - )} -
- - - { - newImage(); - }} - > - - - - - {images.map((image) => ( - setSelectedImageId(image.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setSelectedImageId(image.id); - } - }} - > - {image.status === "complete" && image.url ? ( - {image.prompt - ) : ( - - {image.status === "generating" ? ( - - ) : ( - - )} - - )} - - {image.status !== "generating" && ( - { - event.stopPropagation(); - deleteImage(image.id); - }} - > - - - )} - - ))} - - {images.length === 0 && 暂无历史} - - -
+ +
); } diff --git a/src/components/image-gen/RecentImageInsertFloating.tsx b/src/components/image-gen/RecentImageInsertFloating.tsx new file mode 100644 index 000000000..48ac52bed --- /dev/null +++ b/src/components/image-gen/RecentImageInsertFloating.tsx @@ -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 = { + 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( + 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 ( + + {open && ( + + + + + 最近插图记录 + + + + 清空 + + setOpen(false)}> + 关闭 + + + + + {history.map((entry) => { + const canvasLabel = CANVAS_LABEL_MAP[entry.canvasType] || "画布"; + const locationLabel = entry.locationLabel || "已插入"; + return ( + + + + {entry.imageTitle?.trim() || "图片"} · {canvasLabel} + + {locationLabel} + + handleLocate(entry)}> + + 定位 + + + ); + })} + + + )} + + setOpen((prev) => !prev)}> + {open ? : } + 最近插图 {visibleCount} + + + ); +} + +export default RecentImageInsertFloating; diff --git a/src/components/image-gen/hooks/useImageSearch.test.tsx b/src/components/image-gen/hooks/useImageSearch.test.tsx new file mode 100644 index 000000000..026a0609a --- /dev/null +++ b/src/components/image-gen/hooks/useImageSearch.test.tsx @@ -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; +} + +const mountedRoots: MountedRoot[] = []; + +function mountHook(): HookHarness { + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useImageSearch(); + return null; + } + + renderIntoDom(, 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"); + }); +}); diff --git a/src/components/image-gen/hooks/useImageSearch.ts b/src/components/image-gen/hooks/useImageSearch.ts new file mode 100644 index 000000000..0e74370e0 --- /dev/null +++ b/src/components/image-gen/hooks/useImageSearch.ts @@ -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; + +const DEFAULT_PER_PAGE = 20; + +function pickFirstString(...values: Array): string { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return ""; +} + +function pickFirstNumber(...values: Array): 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("all"); + const [sourceStates, setSourceStates] = useState({ + 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( + "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( + "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, + }; +} diff --git a/src/components/image-gen/tabs/AiImageGenTab.tsx b/src/components/image-gen/tabs/AiImageGenTab.tsx new file mode 100644 index 000000000..fad3d01b4 --- /dev/null +++ b/src/components/image-gen/tabs/AiImageGenTab.tsx @@ -0,0 +1,1476 @@ +/** + * @file AI 生图 Tab + * @description 从原 ImageGenPage 提取的 AI 图片生成功能 + * @module components/image-gen/tabs/AiImageGenTab + */ + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import { + Image as ImageIcon, + ImagePlus, + Loader2, + Plus, + Send, + Settings, + Sparkles, + Trash2, + ExternalLink, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import { useImageGen } from "../useImageGen"; +import type { GeneratedImage } from "../types"; +import { useProjects } from "@/hooks/useProjects"; +import { + getStoredResourceProjectId, + onResourceProjectChange, + setStoredResourceProjectId, +} from "@/lib/resourceProjectSelection"; +import { CharacterMention } from "@/components/agent/chat/components/Inputbar/components/CharacterMention"; +import { SkillBadge } from "@/components/agent/chat/components/Inputbar/components/SkillBadge"; +import { useActiveSkill } from "@/components/agent/chat/components/Inputbar/hooks/useActiveSkill"; +import { skillsApi, type Skill } from "@/lib/api/skills"; +import type { Page } from "@/types/page"; + +export interface AiImageGenTabProps { + /** 目标项目 ID(可选) */ + projectId?: string | null; + /** 导航回调 */ + onNavigate?: (page: Page) => void; +} + +type ResolutionPreset = "1k" | "2k" | "4k"; + +interface ReferenceImageItem { + id: string; + name: string; + url: string; +} + +const RESOLUTION_OPTIONS: Array<{ + label: string; + value: ResolutionPreset; + longEdge: number; +}> = [ + { label: "1K", value: "1k", longEdge: 1024 }, + { label: "2K", value: "2k", longEdge: 2048 }, + { label: "4K", value: "4k", longEdge: 4096 }, +]; + +const ASPECT_RATIO_OPTIONS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "9:16", + "5:4", + "4:5", + "16:9", + "21:9", +]; + +const IMAGE_COUNT_PRESETS = [1, 2, 4, 8]; + +const FALLBACK_SUPPORTED_SIZES = [ + "1024x1024", + "768x1344", + "864x1152", + "1344x768", + "1152x864", +]; + +function parseSize(size: string): { width: number; height: number } | null { + const [rawWidth, rawHeight] = size.split("x"); + const width = Number(rawWidth); + const height = Number(rawHeight); + + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null; + } + + return { width, height }; +} + +function parseAspectRatio(ratio: string): number { + const [rawWidth, rawHeight] = ratio.split(":"); + const width = Number(rawWidth); + const height = Number(rawHeight); + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return 1; + } + + return width / height; +} + +function chooseClosestSize( + supportedSizes: string[], + aspectRatio: string, + resolutionPreset: ResolutionPreset, +): string { + const candidates = supportedSizes + .map((size) => ({ + raw: size, + parsed: parseSize(size), + })) + .filter( + ( + candidate, + ): candidate is { + raw: string; + parsed: { width: number; height: number }; + } => candidate.parsed !== null, + ); + + if (candidates.length === 0) { + return FALLBACK_SUPPORTED_SIZES[0]; + } + + const ratioValue = parseAspectRatio(aspectRatio); + const longEdge = + RESOLUTION_OPTIONS.find((option) => option.value === resolutionPreset) + ?.longEdge || 1024; + + const targetWidth = + ratioValue >= 1 ? longEdge : Math.max(1, Math.round(longEdge * ratioValue)); + const targetHeight = + ratioValue >= 1 ? Math.max(1, Math.round(longEdge / ratioValue)) : longEdge; + + const targetArea = targetWidth * targetHeight; + + const best = candidates.reduce( + (current, candidate) => { + const candidateRatio = candidate.parsed.width / candidate.parsed.height; + const candidateArea = candidate.parsed.width * candidate.parsed.height; + + const ratioScore = Math.abs(Math.log(candidateRatio / ratioValue)); + const areaScore = Math.abs(candidateArea - targetArea) / targetArea; + const totalScore = ratioScore * 3 + areaScore; + + if (totalScore < current.score) { + return { score: totalScore, size: candidate.raw }; + } + + return current; + }, + { score: Number.POSITIVE_INFINITY, size: candidates[0].raw }, + ); + + return best.size; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + + reject(new Error("文件读取失败")); + }; + + reader.onerror = () => reject(new Error("文件读取失败")); + reader.readAsDataURL(file); + }); +} + +function resolveBatchImages( + images: GeneratedImage[], + selectedImageId: string | null, +): GeneratedImage[] { + if (!selectedImageId) { + return []; + } + + const batchMatch = selectedImageId.match(/^img-(\d+)-\d+$/); + if (!batchMatch) { + const single = images.find((item) => item.id === selectedImageId); + return single ? [single] : []; + } + + const batchPrefix = `img-${batchMatch[1]}-`; + return images + .filter((item) => item.id.startsWith(batchPrefix)) + .sort((left, right) => left.createdAt - right.createdAt); +} + +function getStatusText(status: GeneratedImage["status"]): string { + switch (status) { + case "complete": + return "已完成"; + case "error": + return "失败"; + case "generating": + return "生成中"; + default: + return "待生成"; + } +} + +// ==================== Styled Components ==================== + +const Container = styled.div` + flex: 1; + min-height: 0; + display: flex; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +`; + +const ControlPanel = styled.aside` + width: 280px; + min-width: 280px; + padding: 16px 12px; + border-right: 1px solid hsl(var(--border)); + background: hsl(var(--card) / 0.4); + overflow-y: auto; +`; + +const Section = styled.section` + margin-bottom: 18px; +`; + +const SectionTitle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 14px; + font-weight: 600; + color: hsl(var(--foreground)); +`; + +const Hint = styled.div` + margin-top: 6px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const Select = styled.select` + width: 100%; + height: 38px; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--background)); + padding: 0 10px; + font-size: 14px; + color: hsl(var(--foreground)); + + &:focus { + outline: none; + border-color: hsl(var(--primary)); + } +`; + +const FullButton = styled.button<{ $disabled?: boolean }>` + width: 100%; + height: 34px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 13px; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + opacity: ${({ $disabled }) => ($disabled ? 0.65 : 1)}; + + &:hover { + border-color: ${({ $disabled }) => + $disabled ? "hsl(var(--border))" : "hsl(var(--primary) / 0.4)"}; + background: ${({ $disabled }) => + $disabled ? "hsl(var(--background))" : "hsl(var(--accent) / 0.4)"}; + } +`; + +const SmallButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.4); + } +`; + +const UploadBox = styled.div<{ $dragging: boolean }>` + border: 1px dashed + ${({ $dragging }) => + $dragging ? "hsl(var(--primary))" : "hsl(var(--border))"}; + border-radius: 12px; + min-height: 108px; + background: ${({ $dragging }) => + $dragging ? "hsl(var(--primary) / 0.06)" : "hsl(var(--muted) / 0.2)"}; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 10px; + cursor: pointer; +`; + +const UploadText = styled.div` + font-size: 12px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +`; + +const Thumbs = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +`; + +const ThumbItem = styled.div` + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid hsl(var(--border)); + aspect-ratio: 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const RemoveThumb = styled.button` + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border: none; + border-radius: 999px; + background: hsl(var(--background) / 0.9); + color: hsl(var(--destructive)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +`; + +const Segment = styled.div` + display: flex; + gap: 6px; +`; + +const SegmentButton = styled.button<{ $active: boolean }>` + flex: 1; + height: 30px; + border-radius: 8px; + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "transparent")}; + background: ${({ $active }) => + $active ? "hsl(var(--background))" : "hsl(var(--muted) / 0.35)"}; + color: ${({ $active }) => + $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + font-size: 13px; + cursor: pointer; +`; + +const RatioGrid = styled.div` + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +`; + +const RatioButton = styled.button<{ $active: boolean }>` + height: 44px; + border-radius: 8px; + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + background: ${({ $active }) => + $active ? "hsl(var(--primary) / 0.08)" : "hsl(var(--background))"}; + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + font-size: 12px; + font-weight: ${({ $active }) => ($active ? 600 : 500)}; + cursor: pointer; +`; + +const CountRow = styled.div` + display: flex; + gap: 6px; +`; + +const CountButton = styled.button<{ $active: boolean }>` + flex: 1; + height: 30px; + border-radius: 8px; + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "transparent")}; + background: ${({ $active }) => + $active ? "hsl(var(--background))" : "hsl(var(--muted) / 0.35)"}; + color: ${({ $active }) => + $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + font-size: 13px; + cursor: pointer; +`; + +const CountInput = styled.input` + width: 100%; + height: 32px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + padding: 0 10px; + font-size: 13px; + + &:focus { + outline: none; + border-color: hsl(var(--primary)); + } +`; + +const Workspace = styled.main` + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + padding: 14px; +`; + +const Canvas = styled.div` + flex: 1; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--background)); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +`; + +const Empty = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: hsl(var(--muted-foreground)); + + h2 { + margin: 0; + font-size: 48px; + font-weight: 700; + letter-spacing: 2px; + color: hsl(var(--foreground)); + } +`; + +const PreviewImage = styled.img` + max-width: 100%; + max-height: 100%; + object-fit: contain; +`; + +const BatchGrid = styled.div` + width: 100%; + height: 100%; + padding: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + align-content: start; + overflow: auto; +`; + +const BatchItem = styled.button<{ $active: boolean }>` + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + border-radius: 10px; + background: hsl(var(--background)); + cursor: pointer; + display: flex; + flex-direction: column; + padding: 8px; + gap: 8px; +`; + +const BatchPreviewWrap = styled.div` + border-radius: 8px; + background: hsl(var(--muted) / 0.25); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +`; + +const BatchPlaceholder = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 8px; + color: hsl(var(--muted-foreground)); +`; + +const BatchMeta = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const CanvasActions = styled.div` + position: absolute; + top: 12px; + right: 12px; + display: flex; + gap: 8px; +`; + +const CanvasActionButton = styled.button` + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background) / 0.92); + display: inline-flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + } +`; + +const PromptDock = styled.div` + width: 78%; + max-width: 860px; + min-width: 520px; + margin: 12px auto 0; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 14px; + padding: 10px; + display: flex; + align-items: flex-end; + gap: 10px; + + @media (max-width: 1100px) { + width: 90%; + min-width: 0; + } +`; + +const PromptInput = styled.textarea` + flex: 1; + min-height: 44px; + max-height: 140px; + border: none; + resize: none; + background: transparent; + font-size: 14px; + line-height: 1.5; + color: hsl(var(--foreground)); + padding: 8px; + font-family: inherit; + + &:focus { + outline: none; + } + + &::placeholder { + color: hsl(var(--muted-foreground)); + } +`; + +const GenerateButton = styled.button<{ $disabled: boolean }>` + width: 52px; + height: 52px; + border: none; + border-radius: 12px; + background: ${({ $disabled }) => + $disabled ? "hsl(var(--muted))" : "hsl(var(--primary))"}; + color: ${({ $disabled }) => + $disabled + ? "hsl(var(--muted-foreground))" + : "hsl(var(--primary-foreground))"}; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + display: inline-flex; + align-items: center; + justify-content: center; +`; + +const PromptHistoryDock = styled.div` + width: 78%; + max-width: 860px; + min-width: 520px; + margin: 8px auto 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + + @media (max-width: 1100px) { + width: 90%; + min-width: 0; + } +`; + +const PromptHistoryLabel = styled.div` + color: hsl(var(--muted-foreground)); + white-space: nowrap; +`; + +const PromptHistoryChip = styled.button<{ $active: boolean }>` + flex: 1; + max-width: 100%; + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + border-radius: 999px; + background: ${({ $active }) => + $active ? "hsl(var(--primary) / 0.14)" : "hsl(var(--muted) / 0.35)"}; + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + padding: 4px 10px; + font-size: 12px; + line-height: 1.4; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + + &:hover { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + } +`; + +const Status = styled.div` + margin-top: 8px; + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const HistorySidebar = styled.aside` + width: 96px; + min-width: 96px; + border-left: 1px solid hsl(var(--border)); + background: hsl(var(--card) / 0.3); + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +const HistoryNewButton = styled.button` + width: 100%; + height: 40px; + border: 1px dashed hsl(var(--border)); + border-radius: 10px; + background: transparent; + color: hsl(var(--muted-foreground)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.06); + } +`; + +const HistoryList = styled.div` + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 2px; +`; + +const HistoryItem = styled.div<{ $active: boolean }>` + width: 100%; + aspect-ratio: 1; + border-radius: 10px; + border: 1px solid + ${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")}; + background: hsl(var(--background)); + overflow: hidden; + cursor: pointer; + position: relative; + + &:hover { + border-color: hsl(var(--primary) / 0.55); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const HistoryPlaceholder = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); +`; + +const HistoryDeleteButton = styled.button` + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border: 1px solid hsl(var(--destructive) / 0.35); + border-radius: 50%; + background: hsl(var(--background) / 0.92); + color: hsl(var(--destructive)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.15s; + + ${HistoryItem}:hover & { + opacity: 1; + } + + &:hover { + background: hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); + } +`; + +const HistoryEmpty = styled.div` + margin-top: 10px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + text-align: center; +`; + +// ==================== Component ==================== + +export function AiImageGenTab({ projectId, onNavigate }: AiImageGenTabProps) { + const { + availableProviders, + selectedProvider, + selectedProviderId, + setSelectedProviderId, + providersLoading, + availableModels, + selectedModel, + selectedModelId, + setSelectedModelId, + selectedSize, + setSelectedSize, + images, + selectedImage, + selectedImageId, + setSelectedImageId, + generating, + savingToResource, + generateImage, + backfillImagesToResource, + deleteImage, + newImage, + } = useImageGen(); + + const { projects, defaultProject, loading: projectsLoading } = useProjects(); + + const [prompt, setPrompt] = useState(""); + const [skills, setSkills] = useState([]); + const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = + useActiveSkill(); + const promptRef = useRef(null); + const [resolutionPreset, setResolutionPreset] = + useState("1k"); + const [aspectRatio, setAspectRatio] = useState("1:1"); + const [imageCount, setImageCount] = useState(1); + const [isEditingCustomCount, setIsEditingCustomCount] = useState(false); + const [customCountInput, setCustomCountInput] = useState(""); + const [referenceImages, setReferenceImages] = useState( + [], + ); + const [isDraggingUpload, setIsDraggingUpload] = useState(false); + const [targetProjectId, setTargetProjectId] = useState(projectId || ""); + + const fileInputRef = useRef(null); + + // 加载技能列表 + useEffect(() => { + skillsApi + .getAll("proxycast") + .then(setSkills) + .catch((err) => console.error("加载技能列表失败:", err)); + }, []); + + const availableProjects = useMemo( + () => projects.filter((project) => !project.isArchived), + [projects], + ); + + const selectedTargetProject = useMemo( + () => availableProjects.find((project) => project.id === targetProjectId), + [availableProjects, targetProjectId], + ); + + const supportedSizes = useMemo(() => { + return selectedModel?.supportedSizes || FALLBACK_SUPPORTED_SIZES; + }, [selectedModel]); + + const resolvedSize = useMemo(() => { + return chooseClosestSize(supportedSizes, aspectRatio, resolutionPreset); + }, [supportedSizes, aspectRatio, resolutionPreset]); + + useEffect(() => { + if (resolvedSize !== selectedSize) { + setSelectedSize(resolvedSize); + } + }, [resolvedSize, selectedSize, setSelectedSize]); + + // 初始化或更新目标项目 ID + useEffect(() => { + if (projectsLoading) { + return; + } + + setTargetProjectId((current) => { + // 如果外部传入了 projectId 且不同于当前值,使用外部值 + if (projectId && projectId !== current) { + return projectId; + } + + // 如果当前值有效且在可用项目中,保持不变 + if (current && availableProjects.some((project) => project.id === current)) { + return current; + } + + // 尝试从存储中获取 + const storedProjectId = getStoredResourceProjectId({ includeLegacy: true }); + if ( + storedProjectId && + availableProjects.some((project) => project.id === storedProjectId) + ) { + return storedProjectId; + } + + // 使用默认项目 + const preferredProject = + (defaultProject && !defaultProject.isArchived ? defaultProject : null) ?? + availableProjects[0]; + + return preferredProject?.id || ""; + }); + }, [projectsLoading, availableProjects, defaultProject, projectId]); + + useEffect(() => { + setStoredResourceProjectId(targetProjectId, { + source: "image-gen-target", + syncLegacy: true, + emitEvent: true, + }); + }, [targetProjectId]); + + useEffect(() => { + return onResourceProjectChange((detail) => { + if (detail.source !== "resources") { + return; + } + + const nextProjectId = detail.projectId; + if (!nextProjectId || nextProjectId === targetProjectId) { + return; + } + + if (!availableProjects.some((project) => project.id === nextProjectId)) { + return; + } + + setTargetProjectId(nextProjectId); + }); + }, [availableProjects, targetProjectId]); + + const canGenerate = + !!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating; + + const selectedBatchImages = useMemo(() => { + return resolveBatchImages(images, selectedImageId); + }, [images, selectedImageId]); + + const selectedPromptHistory = useMemo(() => { + return selectedImage?.prompt.trim() || ""; + }, [selectedImage]); + + const isFalProvider = + selectedProvider?.id === "fal" || selectedProvider?.type === "fal"; + + const shouldShowBatchGrid = selectedBatchImages.length > 1; + + const handleCountSelect = (count: number) => { + setImageCount(count); + setIsEditingCustomCount(false); + }; + + const handleCustomCountConfirm = () => { + const next = Number(customCountInput); + if (!Number.isFinite(next)) return; + const normalized = Math.max(1, Math.min(8, Math.floor(next))); + setImageCount(normalized); + setIsEditingCustomCount(false); + setCustomCountInput(""); + }; + + const handleReferenceFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + + const remain = Math.max(0, 3 - referenceImages.length); + if (remain === 0) return; + + const selectedFiles = Array.from(files) + .filter((file) => file.type.startsWith("image/")) + .slice(0, remain); + + if (selectedFiles.length === 0) return; + + const loaded = await Promise.all( + selectedFiles.map(async (file) => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + url: await fileToDataUrl(file), + })), + ); + + setReferenceImages((prev) => [...prev, ...loaded].slice(0, 3)); + }; + + const handleUploadChange = async ( + event: React.ChangeEvent, + ) => { + await handleReferenceFiles(event.target.files); + event.target.value = ""; + }; + + const handleGenerate = async () => { + if (!canGenerate) return; + + const finalPrompt = activeSkill + ? wrapTextWithSkill(prompt.trim()) + : prompt.trim(); + + try { + await generateImage(finalPrompt, { + imageCount, + referenceImages: referenceImages.map((item) => item.url), + size: resolvedSize, + targetProjectId: targetProjectId || undefined, + }); + setPrompt(""); + clearActiveSkill(); + } catch (error) { + console.error("图片生成失败:", error); + } + }; + + const handleBackfillToResource = async () => { + if (!targetProjectId) { + toast.error("请先选择目标资源库"); + return; + } + + try { + const result = await backfillImagesToResource(targetProjectId); + if (result.failed > 0) { + toast.error(`补录完成:成功 ${result.saved},失败 ${result.failed}`); + } else { + toast.success(`补录完成:新增 ${result.saved},跳过 ${result.skipped}`); + } + + if (result.errors.length > 0) { + console.warn("[ImageGen] 历史补录失败详情:", result.errors); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`补录失败: ${message}`); + } + }; + + const handlePromptKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleGenerate(); + } + }; + + const goCredentialManagement = () => { + onNavigate?.("provider-pool"); + }; + + return ( + + + {availableProviders.length > 1 && ( +
+ 服务商 + +
+ )} + +
+ + 模型 + + + + + + 当前服务商:{selectedProvider?.name || "未选择"} +
+ +
+ 目标资源库 + + + {targetProjectId + ? `生成成功后会自动写入「${selectedTargetProject?.name || "已选项目"}」资源库` + : "未启用自动入库,生成结果仅保存在当前页面历史"} + + { + void handleBackfillToResource(); + }} + $disabled={savingToResource || !targetProjectId || images.length === 0} + disabled={savingToResource || !targetProjectId || images.length === 0} + > + {savingToResource ? "补录中..." : "补录历史到资源库"} + +
+ +
+ 参考图 + {referenceImages.length > 0 ? ( + + {referenceImages.map((item) => ( + + {item.name} + { + setReferenceImages((prev) => + prev.filter((current) => current.id !== item.id), + ); + }} + > + + + + ))} + + ) : ( + fileInputRef.current?.click()} + onDragOver={(event) => { + event.preventDefault(); + setIsDraggingUpload(true); + }} + onDragLeave={(event) => { + event.preventDefault(); + setIsDraggingUpload(false); + }} + onDrop={async (event) => { + event.preventDefault(); + setIsDraggingUpload(false); + await handleReferenceFiles(event.dataTransfer.files); + }} + > + + +
点击或拖拽上传图片
+
支持最多 3 张图片
+
+
+ )} + + + {isFalProvider + ? "Fal 上传参考图会启用图片编辑参数;Nano Banana 会优先尝试 /edit 接口。" + : "上传参考图会随请求发送给模型,是否执行编辑由模型能力决定。"} + +
+ +
+ 分辨率 + + {RESOLUTION_OPTIONS.map((option) => ( + setResolutionPreset(option.value)} + > + {option.label} + + ))} + +
+ +
+ 宽高比 + + {ASPECT_RATIO_OPTIONS.map((ratio) => ( + setAspectRatio(ratio)} + > + {ratio} + + ))} + +
+ +
+ 图片数量 + {isEditingCustomCount ? ( + setCustomCountInput(event.target.value)} + onBlur={handleCustomCountConfirm} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleCustomCountConfirm(); + } + }} + autoFocus + /> + ) : ( + + {IMAGE_COUNT_PRESETS.map((count) => ( + handleCountSelect(count)} + > + {count} + + ))} + { + setCustomCountInput(String(imageCount)); + setIsEditingCustomCount(true); + }} + > + + + + + )} +
+ + 实际输出尺寸:{resolvedSize} + {selectedImage?.status === "complete" && targetProjectId && ( + + {selectedImage.resourceMaterialId && + selectedImage.resourceProjectId === targetProjectId + ? "当前图片已同步到资源库" + : selectedImage.resourceSaveError + ? `当前图片入库失败:${selectedImage.resourceSaveError}` + : savingToResource + ? "当前图片正在同步到资源库..." + : "当前图片尚未同步到资源库"} + + )} +
+ + + + {shouldShowBatchGrid ? ( + + {selectedBatchImages.map((item, index) => { + const parsedSize = parseSize(item.size); + const previewStyle = parsedSize + ? { + aspectRatio: `${parsedSize.width}/${parsedSize.height}`, + } + : undefined; + + return ( + setSelectedImageId(item.id)} + > + + {item.status === "complete" && item.url ? ( + {item.prompt + ) : ( + + {item.status === "error" ? ( + + ) : ( + + )} + {getStatusText(item.status)} + + )} + + + + 第 {index + 1} 张 + {getStatusText(item.status)} + + + ); + })} + + ) : selectedImage?.status === "complete" && selectedImage.url ? ( + <> + + + window.open(selectedImage.url, "_blank")} + > + + + deleteImage(selectedImage.id)} + > + + + + + ) : selectedImage?.status === "error" ? ( + + +

生成失败

+
{selectedImage.error || "请重试"}
+
+ ) : ( + + {generating || selectedImage?.status === "generating" ? ( + + ) : ( + + )} +

绘画

+
+ )} + + {shouldShowBatchGrid && + selectedImage?.status === "complete" && + selectedImage.url && ( + + window.open(selectedImage.url, "_blank")} + > + + + deleteImage(selectedImage.id)} + > + + + + )} +
+ + + {/* CharacterMention */} + {skills.length > 0 && ( + + )} + {/* Skill Badge */} + {activeSkill && ( + + )} + setPrompt(event.target.value)} + onKeyDown={handlePromptKeyDown} + placeholder="描述你想要生成的内容" + disabled={!selectedProvider || !selectedModelId || generating} + /> + + {generating ? ( + + ) : ( + + )} + + + {selectedPromptHistory && ( + + 当前图片提示词 + setPrompt(selectedPromptHistory)} + > + {selectedPromptHistory} + + + )} + + {!selectedProvider && ( + + 当前没有可用绘画服务,请先到凭证管理添加可用 Provider。 + + )} +
+ + + { + newImage(); + }} + > + + + + + {images.map((image) => ( + setSelectedImageId(image.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedImageId(image.id); + } + }} + > + {image.status === "complete" && image.url ? ( + {image.prompt + ) : ( + + {image.status === "generating" ? ( + + ) : ( + + )} + + )} + + {image.status !== "generating" && ( + { + event.stopPropagation(); + deleteImage(image.id); + }} + > + + + )} + + ))} + + {images.length === 0 && 暂无历史} + + +
+ ); +} + +export default AiImageGenTab; diff --git a/src/components/image-gen/tabs/ImageSearchTab.test.tsx b/src/components/image-gen/tabs/ImageSearchTab.test.tsx new file mode 100644 index 000000000..3d9b497b0 --- /dev/null +++ b/src/components/image-gen/tabs/ImageSearchTab.test.tsx @@ -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 }) => ( +
{children}
+ ), +})); + +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( + , + 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(); + }); +}); diff --git a/src/components/image-gen/tabs/ImageSearchTab.tsx b/src/components/image-gen/tabs/ImageSearchTab.tsx new file mode 100644 index 000000000..b98a9d8d1 --- /dev/null +++ b/src/components/image-gen/tabs/ImageSearchTab.tsx @@ -0,0 +1,1133 @@ +/** + * @file 图片搜索 Tab + * @description 使用 Pixabay API 搜索在线图片,参考 turbodesk 排版优化 + * @module components/image-gen/tabs/ImageSearchTab + */ + +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { open as openExternal } from "@tauri-apps/plugin-shell"; +import { + Search, + Loader2, + Image as ImageIcon, + ExternalLink, + Download, + ChevronDown, + Globe, + ImagePlus, + FilePlus2, +} from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { toast } from "sonner"; +import styled, { keyframes } from "styled-components"; +import { + useImageSearch, + type AspectRatioFilter, + type SearchSource, +} from "../hooks/useImageSearch"; +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 ImageSearchTabProps { + /** 目标项目 ID */ + projectId?: string | null; + /** 页面跳转 */ + onNavigate?: (page: Page, params?: PageParams) => void; +} + +const CANVAS_DISPLAY_NAME: Record = { + auto: "当前画布", + document: "文档", + novel: "小说", + script: "剧本", + music: "音乐", + poster: "海报", + video: "视频", +}; + +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, 5); +} + +// ==================== Animations ==================== + +const fadeIn = keyframes` + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +`; + +const shimmer = keyframes` + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +`; + +// ==================== Styled Components ==================== + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: hsl(var(--background)); +`; + +const SearchPanel = styled.div` + padding: 20px 24px 16px; + display: flex; + flex-direction: column; + gap: 12px; + border-bottom: 1px solid hsl(var(--border) / 0.3); +`; + +const PromptArea = styled.textarea` + width: 100%; + min-height: 80px; + max-height: 140px; + padding: 14px 16px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--card) / 0.5); + color: hsl(var(--foreground)); + font-size: 14px; + line-height: 1.6; + resize: none; + font-family: inherit; + transition: all 0.2s ease; + + &:focus { + outline: none; + border-color: hsl(var(--primary) / 0.6); + background: hsl(var(--card) / 0.8); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.08); + } + + &::placeholder { + color: hsl(var(--muted-foreground) / 0.6); + } +`; + +const FiltersRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const RatioDropdown = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid hsl(var(--border)); + border-radius: 10px; + background: hsl(var(--card) / 0.5); + color: hsl(var(--foreground)); + font-size: 13px; + cursor: pointer; + position: relative; + transition: all 0.2s; + + &:hover { + border-color: hsl(var(--primary) / 0.4); + background: hsl(var(--card) / 0.8); + } +`; + +const RatioOptions = styled.div<{ $open: boolean }>` + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + min-width: 140px; + padding: 6px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--popover)); + box-shadow: 0 8px 32px hsl(var(--background) / 0.5); + display: ${({ $open }) => ($open ? "flex" : "none")}; + flex-direction: column; + gap: 2px; + backdrop-filter: blur(20px); +`; + +const RatioOption = styled.div<{ $active: boolean }>` + padding: 8px 12px; + border: none; + border-radius: 8px; + background: ${({ $active }) => + $active ? "hsl(var(--primary) / 0.12)" : "transparent"}; + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--foreground))"}; + font-size: 13px; + font-weight: ${({ $active }) => ($active ? 600 : 400)}; + cursor: pointer; + text-align: left; + transition: all 0.15s; + user-select: none; + + &:hover { + background: ${({ $active }) => + $active ? "hsl(var(--primary) / 0.15)" : "hsl(var(--accent))"}; + } +`; + +const SearchButton = styled.button<{ $loading: boolean }>` + width: 100%; + height: 44px; + border: none; + border-radius: 12px; + background: linear-gradient( + 135deg, + hsl(var(--primary)), + hsl(var(--primary) / 0.8) + ); + color: hsl(var(--primary-foreground)); + font-size: 14px; + font-weight: 600; + cursor: ${({ $loading }) => ($loading ? "wait" : "pointer")}; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.25s ease; + position: relative; + overflow: hidden; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 24px hsl(var(--primary) / 0.3); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } + + &::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + hsl(0 0% 100% / 0.15), + transparent + ); + background-size: 200% 100%; + animation: ${({ $loading }) => ($loading ? shimmer : "none")} 1.5s infinite; + } +`; + +const SourceTabs = styled.div` + display: flex; + border: 1px solid hsl(var(--border) / 0.8); + border-radius: 12px; + overflow: hidden; + background: hsl(var(--muted) / 0.35); +`; + +const SourceTab = styled.button<{ $active: boolean }>` + flex: 1; + padding: 9px 16px; + border: none; + background: ${({ $active }) => + $active + ? "linear-gradient(135deg, hsl(var(--primary) / 0.22), hsl(var(--primary) / 0.1))" + : "transparent"}; + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}; + font-size: 13px; + font-weight: ${({ $active }) => ($active ? 700 : 500)}; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + + &:first-child { + border-right: 1px solid hsl(var(--border) / 0.7); + } + + &:hover { + color: ${({ $active }) => + $active ? "hsl(var(--primary))" : "hsl(var(--foreground))"}; + background: ${({ $active }) => + $active + ? "linear-gradient(135deg, hsl(var(--primary) / 0.25), hsl(var(--primary) / 0.12))" + : "hsl(var(--accent) / 0.4)"}; + } + + &::after { + content: ""; + position: absolute; + left: 10px; + right: 10px; + bottom: 2px; + height: 2px; + border-radius: 2px; + background: ${({ $active }) => + $active ? "hsl(var(--primary))" : "transparent"}; + } +`; + +const ResultsArea = styled.div` + flex: 1; + min-height: 0; +`; + +const ImageGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; + padding: 16px 24px 24px; +`; + +const ImageCard = styled.div<{ $aspectRatio?: number }>` + position: relative; + aspect-ratio: ${({ $aspectRatio }) => + $aspectRatio ? Math.max(0.75, Math.min(1.6, $aspectRatio)) : 4 / 3}; + min-height: 180px; + max-height: 320px; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + animation: ${fadeIn} 0.35s ease both; + transition: all 0.25s ease; + + &:hover { + transform: translateY(-3px); + box-shadow: 0 12px 40px hsl(var(--background) / 0.6); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s ease; + } + + &:hover img { + transform: scale(1.06); + } +`; + +const Overlay = styled.div` + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + transparent 40%, + hsl(var(--background) / 0.85) 100% + ); + opacity: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 16px; + gap: 8px; + transition: opacity 0.25s ease; + + ${ImageCard}:hover & { + opacity: 1; + } +`; + +const OverlayActions = styled.div` + display: flex; + gap: 8px; + width: 100%; +`; + +const ActionButton = styled.button<{ $primary?: boolean }>` + flex: 1; + height: 36px; + border: ${({ $primary }) => + $primary ? "none" : "1px solid hsl(var(--border) / 0.6)"}; + border-radius: 10px; + background: ${({ $primary }) => + $primary ? "hsl(var(--primary))" : "hsl(var(--background) / 0.8)"}; + color: ${({ $primary }) => + $primary ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"}; + font-size: 12px; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + backdrop-filter: blur(12px); + transition: all 0.2s ease; + + &:hover { + transform: scale(1.03); + box-shadow: 0 2px 8px hsl(var(--background) / 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: wait; + } +`; + +const MetaBadge = styled.div` + position: absolute; + top: 8px; + right: 8px; + padding: 3px 8px; + border-radius: 6px; + background: hsl(var(--background) / 0.75); + backdrop-filter: blur(8px); + font-size: 10px; + color: hsl(var(--foreground) / 0.8); + opacity: 0; + transition: opacity 0.2s; + + ${ImageCard}:hover & { + opacity: 1; + } +`; + +const ProviderBadge = styled.div` + position: absolute; + bottom: 8px; + left: 8px; + padding: 3px 8px; + border-radius: 6px; + background: hsl(var(--primary) / 0.85); + backdrop-filter: blur(8px); + font-size: 10px; + color: hsl(var(--primary-foreground)); + display: flex; + align-items: center; + gap: 4px; + opacity: 0; + transition: opacity 0.2s; + + ${ImageCard}:hover & { + opacity: 1; + } +`; + +const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 48px; + gap: 16px; + color: hsl(var(--muted-foreground)); + text-align: center; +`; + +const EmptyIcon = styled.div` + width: 80px; + height: 80px; + border-radius: 24px; + background: hsl(var(--muted) / 0.3); + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground) / 0.5); +`; + +const EmptyTitle = styled.h3` + margin: 0; + font-size: 16px; + font-weight: 600; + color: hsl(var(--foreground) / 0.7); +`; + +const EmptyHint = styled.p` + margin: 0; + font-size: 13px; + color: hsl(var(--muted-foreground)); +`; + +const Footer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 24px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + border-top: 1px solid hsl(var(--border) / 0.3); +`; + +const Attribution = styled.a` + color: hsl(var(--primary) / 0.8); + text-decoration: none; + font-weight: 500; + transition: color 0.2s; + + &:hover { + color: hsl(var(--primary)); + text-decoration: underline; + } +`; + +const LoadMoreButton = styled.button<{ $loading: boolean }>` + width: calc(100% - 48px); + margin: 0 24px 16px; + height: 42px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--card) / 0.5); + color: hsl(var(--foreground)); + font-size: 13px; + font-weight: 500; + cursor: ${({ $loading }) => ($loading ? "wait" : "pointer")}; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + + &:hover:not(:disabled) { + border-color: hsl(var(--primary) / 0.4); + background: hsl(var(--card) / 0.8); + transform: translateY(-1px); + } + + &:disabled { + opacity: 0.5; + } +`; + +const ResultCount = styled.span` + font-size: 13px; + color: hsl(var(--muted-foreground)); + padding: 0 24px; + display: block; + margin-bottom: 4px; +`; + +const RecentInsertPanel = styled.div` + margin: 10px 24px 0; + border: 1px solid hsl(var(--border) / 0.55); + border-radius: 12px; + background: hsl(var(--card) / 0.45); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const RecentInsertHeader = styled.div` + font-size: 12px; + color: hsl(var(--muted-foreground)); +`; + +const RecentInsertList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const RecentInsertItem = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +const RecentInsertMeta = styled.div` + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +`; + +const RecentInsertTitle = styled.div` + font-size: 12px; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const RecentInsertHint = styled.div` + font-size: 11px; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const RelocateButton = styled.button` + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 11px; + padding: 4px 10px; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; + + &:hover { + border-color: hsl(var(--primary) / 0.45); + color: hsl(var(--primary)); + } +`; + +// ==================== Constants ==================== + +const SOURCE_TABS = [ + { key: "web", label: "联网搜索", icon: Globe }, + { key: "pixabay", label: "Pixabay图库", icon: ImagePlus }, +] as const; + +const SOURCE_HINTS: Record< + SearchSource, + { + resultLabel: string; + attributionName: string; + attributionUrl: string; + } +> = { + web: { + resultLabel: "Pexels 图库", + attributionName: "Pexels", + attributionUrl: "https://www.pexels.com", + }, + pixabay: { + resultLabel: "Pixabay 图库", + attributionName: "Pixabay", + attributionUrl: "https://pixabay.com", + }, +}; + +const RATIO_OPTIONS: Array<{ + value: AspectRatioFilter; + label: string; + icon: string; +}> = [ + { value: "all", label: "不限比例", icon: "⬜" }, + { value: "landscape", label: "横向", icon: "▬" }, + { value: "portrait", label: "纵向", icon: "▮" }, + { value: "square", label: "方形", icon: "◻" }, +]; + +// ==================== Component ==================== + +export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { + const { + query, + setQuery, + aspectRatio, + setAspectRatio, + sourceStates, + search, + loadMore, + } = useImageSearch(); + + const [savingId, setSavingId] = useState(null); + const [ratioOpen, setRatioOpen] = useState(false); + const [searchSource, setSearchSource] = useState("web"); + const pendingInsertRequestMetaRef = useRef< + Map< + string, + { + projectId: string; + contentId: string | null; + canvasType: CanvasImageTargetType; + theme: string; + imageTitle?: string; + } + > + >(new Map()); + const [recentInsertHistory, setRecentInsertHistory] = useState< + CanvasImageInsertHistoryEntry[] + >(() => getVisibleInsertHistory(projectId)); + + const currentState = sourceStates[searchSource]; + const results = currentState.results; + const loading = currentState.loading; + const total = currentState.total; + const error = currentState.error; + const lastQuery = currentState.lastQuery; + const hasMore = results.length < total; + const sourceHint = SOURCE_HINTS[searchSource]; + + const currentRatioLabel = + RATIO_OPTIONS.find((opt) => opt.value === aspectRatio)?.label || "不限比例"; + + 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) { + const canvasLabel = CANVAS_DISPLAY_NAME[ack.canvasType] || "目标画布"; + const locationLabel = ack.locationLabel ? ` · ${ack.locationLabel}` : ""; + toast.success(`已插入到${canvasLabel}${locationLabel}`); + + 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, 5), + ); + } else { + toast.error("插入失败,请返回创作区重试"); + } + }); + + return unsubscribe; + }, [projectId]); + + const handleSearch = () => { + if (!query.trim()) { + toast.error("请输入搜索关键词"); + return; + } + void search(searchSource, query, true); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSearch(); + } + }; + + const handleSaveImage = async ( + imageUrl: string, + imageName: string, + provider: "pixabay" | "pexels", + ) => { + if (!projectId) { + toast.error("请先选择项目"); + return; + } + + setSavingId(imageUrl); + try { + await invoke("import_material_from_url", { + req: { + projectId, + name: imageName, + type: "image", + url: imageUrl, + tags: [provider], + }, + }); + toast.success("已保存到图片库"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`保存失败: ${message}`); + } finally { + setSavingId(null); + } + }; + + const handleInsertImageToCanvas = (image: { + id: string; + previewUrl: string; + largeUrl: string; + pageUrl: string; + tags: string; + width: number; + height: number; + provider: "pixabay" | "pexels"; + }) => { + if (!projectId) { + 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: image.provider === "pexels" ? "pexels" : "pixabay", + image: { + id: image.id, + previewUrl: image.previewUrl, + contentUrl: image.largeUrl || image.previewUrl, + pageUrl: image.pageUrl, + title: image.tags, + width: image.width, + height: image.height, + attributionName: image.provider === "pexels" ? "Pexels" : "Pixabay", + provider: image.provider, + }, + }); + pendingInsertRequestMetaRef.current.set(request.requestId, { + projectId, + contentId: targetContentId, + canvasType: targetCanvasType, + theme: targetTheme, + imageTitle: image.tags, + }); + + onNavigate?.("agent", { + projectId, + contentId: targetContentId ?? undefined, + theme: targetTheme, + lockTheme: false, + }); + + const canvasLabel = CANVAS_DISPLAY_NAME[targetCanvasType] || "当前画布"; + toast.success(`已发送到${canvasLabel},正在自动定位`); + }; + + const handleRelocateToInsert = (entry: CanvasImageInsertHistoryEntry) => { + onNavigate?.("agent", { + projectId: entry.projectId, + contentId: entry.contentId ?? undefined, + theme: entry.theme, + lockTheme: false, + }); + const canvasLabel = CANVAS_DISPLAY_NAME[entry.canvasType] || "目标画布"; + toast.success(`正在定位到${canvasLabel}`); + }; + + const openPreviewWindow = async (url: string) => { + if (!url) return; + try { + await openExternal(url); + } catch (error) { + console.error("打开预览窗口失败:", error); + window.open(url, "_blank"); + } + }; + + return ( + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + rows={3} + /> + + + setRatioOpen(!ratioOpen)} + onBlur={() => setTimeout(() => setRatioOpen(false), 150)} + > + + {currentRatioLabel} + + + {RATIO_OPTIONS.map((option) => ( + { + e.preventDefault(); + setAspectRatio(option.value); + setRatioOpen(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setAspectRatio(option.value); + setRatioOpen(false); + } + }} + > + {option.icon} {option.label} + + ))} + + + + + + {loading ? ( + <> + + 搜索中... + + ) : ( + <> + + 搜索图片 + + )} + + + + {SOURCE_TABS.map((tab) => ( + setSearchSource(tab.key as SearchSource)} + > + + {tab.label} + + ))} + + + {recentInsertHistory.length > 0 && ( + + 最近插入记录(可一键定位) + + {recentInsertHistory.map((entry) => { + const canvasLabel = CANVAS_DISPLAY_NAME[entry.canvasType] || "画布"; + const locationLabel = entry.locationLabel || "已插入"; + return ( + + + + {entry.imageTitle?.trim() || "图片"} · {canvasLabel} + + {locationLabel} + + handleRelocateToInsert(entry)} + > + 再次定位 + + + ); + })} + + + )} + + + + + {results.length === 0 && !loading ? ( + + + + + 搜索海量图片 + + {error + ? `搜索失败:${error}` + : lastQuery + ? `未找到与「${lastQuery}」相关的图片,建议尝试英文关键词或更换来源。` + : `输入关键词搜索图片,结果来自 ${sourceHint.resultLabel}`} + + + ) : ( + <> + {results.length > 0 && ( + + 共找到 {total.toLocaleString()} 张图片 + {results.length < total && `,已加载 ${results.length} 张`} + + )} + + + {results.map((img, index) => { + const ratio = img.width / img.height; + const isSaving = savingId === img.largeUrl; + const displayName = img.tags.split(",")[0]?.trim() || "图片"; + + return ( + { + void openPreviewWindow(img.largeUrl || img.pageUrl); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void openPreviewWindow(img.largeUrl || img.pageUrl); + } + }} + > + {img.tags} + + {img.width}×{img.height} + + + {img.provider === "pixabay" ? "Pixabay" : "Pexels"} + + + + { + e.stopPropagation(); + handleSaveImage( + img.largeUrl, + displayName, + img.provider, + ); + }} + disabled={isSaving} + > + {isSaving ? ( + + ) : ( + <> + + 保存 + + )} + + { + e.stopPropagation(); + handleInsertImageToCanvas(img); + }} + > + + 插入当前画布 + + { + e.stopPropagation(); + void openPreviewWindow(img.pageUrl); + }} + > + + 预览 + + + + + ); + })} + + + {hasMore && ( + loadMore(searchSource)} + disabled={loading} + > + {loading ? ( + <> + + 加载中... + + ) : ( + "加载更多" + )} + + )} + + )} + + + + {results.length > 0 && ( +
+ + 已显示 {results.length} / {total.toLocaleString()} 张 + + + 图片来源: {sourceHint.attributionName} + +
+ )} +
+ ); +} + +export default ImageSearchTab; diff --git a/src/components/image-gen/tabs/LocalImageTab.tsx b/src/components/image-gen/tabs/LocalImageTab.tsx new file mode 100644 index 000000000..89a9d3cad --- /dev/null +++ b/src/components/image-gen/tabs/LocalImageTab.tsx @@ -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(null); + const [selectedPath, setSelectedPath] = useState(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 ( + + + + + +
+ 请先选择项目 +
+
+ 在右上角选择一个项目后即可上传本地图片 +
+
+
+ ); + } + + return ( + + + + {previewUrl ? ( + + + 预览 + + + + + 重新选择 + + + {saving ? ( + <> + + 保存中... + + ) : ( + <> + + 保存到图片库 + + )} + + + + ) : ( + + + + + 选择本地图片 + + 支持 JPG、PNG、WebP、GIF、BMP 格式 +
+ 点击此区域选择文件 +
+
+ )} +
+
+
+ ); +} + +export default LocalImageTab; diff --git a/src/components/image-gen/tabs/MyGalleryTab.test.tsx b/src/components/image-gen/tabs/MyGalleryTab.test.tsx new file mode 100644 index 000000000..2f2480b10 --- /dev/null +++ b/src/components/image-gen/tabs/MyGalleryTab.test.tsx @@ -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 ( +
+ + +
+ ); + }, +})); + +import { MyGalleryTab } from "./MyGalleryTab"; + +const mountedRoots: MountedRoot[] = []; + +function renderTab(projectId: string | null = "project-1"): HTMLDivElement { + const mounted = renderIntoDom(, 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); + }); +}); diff --git a/src/components/image-gen/tabs/MyGalleryTab.tsx b/src/components/image-gen/tabs/MyGalleryTab.tsx new file mode 100644 index 000000000..42979cf82 --- /dev/null +++ b/src/components/image-gen/tabs/MyGalleryTab.tsx @@ -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( + 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 ( + + + + + + 请先选择项目 + 在右上角选择一个项目后即可查看图片库 + + + ); + } + + return ( + + + + {selectedMaterial + ? `已选中:${selectedMaterial.name}` + : "可双击图片直接插入当前画布,或先单击选中后点击右侧按钮"} + +
+ {recentInsertHistory[0] && ( + handleRelocate(recentInsertHistory[0])} + > + 再次定位 + + )} + { + if (!selectedMaterial) { + return; + } + handleInsertFromGallery(selectedMaterial); + }} + > + 插入选中图片到当前画布 + +
+
+ { + setSelectedMaterial(materials[0] || null); + }} + onDoubleClick={handleInsertFromGallery} + /> +
+ ); +} + +export default MyGalleryTab; diff --git a/src/components/image-gen/tabs/index.ts b/src/components/image-gen/tabs/index.ts new file mode 100644 index 000000000..ca3d5381d --- /dev/null +++ b/src/components/image-gen/tabs/index.ts @@ -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"; diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 59a12cabb..febc7d1de 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -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(null); - const [selectedPlugins, setSelectedPlugins] = useState([]); - const [installResults, setInstallResults] = useState( - [], - ); 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 ; case 2: - return ( - - ); - case 3: - return ( - - ); - case 4: - return ( - - ); - case 5: return ( ); - case 6: + case 3: return ( ); - case 7: + case 4: return ( ); - case 8: - return ( - - ); + case 5: + return ; 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 ( @@ -310,12 +229,8 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) { - diff --git a/src/components/onboarding/steps/CompleteStep.tsx b/src/components/onboarding/steps/CompleteStep.tsx index bcdec2fee..1f54d8c92 100644 --- a/src/components/onboarding/steps/CompleteStep.tsx +++ b/src/components/onboarding/steps/CompleteStep.tsx @@ -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 ( @@ -107,40 +63,11 @@ export function CompleteStep({ installResults, onFinish }: CompleteStepProps) { 设置完成! - - {hasResults - ? successCount === installResults.length - ? "所有插件已成功安装,您可以开始使用 ProxyCast 了。" - : `已安装 ${successCount} 个插件${failedCount > 0 ? `,${failedCount} 个安装失败` : ""}。您可以稍后在插件中心重试。` - : "您已跳过插件安装,可以稍后在插件中心安装需要的插件。"} - + ProxyCast 已准备就绪,您可以开始使用了。 - {hasResults && ( - - {installResults.map((result) => { - const plugin = onboardingPlugins.find( - (p) => p.id === result.pluginId, - ); - const isSuccess = result.status === "complete"; - - return ( - - {isSuccess ? : } - - {plugin?.name || result.pluginId} - {isSuccess ? " 安装成功" : " 安装失败"} - - - ); - })} - - )} - - {!hasResults && ( - - 提示:您可以在左侧导航栏的"插件中心"随时安装插件 - - )} + + 提示:您可以在左侧导航栏的"插件中心"随时安装插件 + - - + + ); } diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 5ae8641f0..87786dbc3 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -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 ( - <> - - - - ); - case SettingsTabs.SecurityPerformance: return ( <> diff --git a/src/components/settings-v2/agent/image-gen/index.test.tsx b/src/components/settings-v2/agent/image-gen/index.test.tsx new file mode 100644 index 000000000..b293c9994 --- /dev/null +++ b/src/components/settings-v2/agent/image-gen/index.test.tsx @@ -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(); + }); + 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("设置已保存"); + }); +}); diff --git a/src/components/settings-v2/general/chat-appearance/index.tsx b/src/components/settings-v2/general/chat-appearance/index.tsx index d077e9fcf..95958cc33 100644 --- a/src/components/settings-v2/general/chat-appearance/index.tsx +++ b/src/components/settings-v2/general/chat-appearance/index.tsx @@ -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; diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index 6a63f7d1b..8f9aa658d 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -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", "安全与性能"), diff --git a/src/components/settings-v2/system/about/index.tsx b/src/components/settings-v2/system/about/index.tsx index e0d15e28e..9e5822cc5 100644 --- a/src/components/settings-v2/system/about/index.tsx +++ b/src/components/settings-v2/system/about/index.tsx @@ -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({ current: "", latest: undefined, @@ -41,8 +63,6 @@ export function AboutSection() { const [downloadResult, setDownloadResult] = useState( null, ); - const [toolVersions, setToolVersions] = useState([]); - 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("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() {

ProxyCast

-

AI API 代理服务

+

+ {t("创作类 AI Agent 平台", "创作类 AI Agent 平台")} +

+

+ {t( + "把灵感、写作、出图、改稿、沉淀放进同一个工作台", + "把灵感、写作、出图、改稿、沉淀放进同一个工作台", + )} +

- 版本 {versionInfo.current} + + {t("版本 {{version}}", { + version: versionInfo.current, + defaultValue: "版本 {{version}}", + })} + {versionInfo.hasUpdate ? ( - 有新版本 {versionInfo.latest} + {t("有新版本 {{version}}", { + version: versionInfo.latest ?? "", + defaultValue: "有新版本 {{version}}", + })} ) : versionInfo.error ? ( @@ -151,7 +175,7 @@ export function AboutSection() { ) : versionInfo.latest ? ( - 已是最新 + {t("已是最新", "已是最新")} ) : null}
@@ -165,7 +189,7 @@ export function AboutSection() { - 检查更新 + {t("检查更新", "检查更新")} {versionInfo.hasUpdate && ( @@ -178,7 +202,9 @@ export function AboutSection() { - {downloading ? "下载中..." : "下载更新"} + {downloading + ? t("下载中...", "下载中...") + : t("下载更新", "下载更新")} {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" > - 网页下载 + {t("网页下载", "网页下载")} )} @@ -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" - > - - 前往网页下载 + className="inline-flex items-center gap-1 mt-2 underline hover:no-underline" + > + + {t("前往网页下载", "前往网页下载")} )} @@ -232,7 +258,7 @@ export function AboutSection() { {/* 链接 */} - {/* 使用说明 Q&A */} + {/* 产品定位 */}
-

使用说明

-
- - - - - +

{t("产品定位", "产品定位")}

+
+

+ {t( + "ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。", + "ProxyCast 是面向普通创作者的 AI Agent 平台。你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成对话定方向、生成内容与素材、持续迭代修改,并把结果沉淀成可复用资产。", + )} +

+

+ {t("一句话:", "一句话:")} + {t("从“想到”直接走到“可发布”。", "从“想到”直接走到“可发布”。")} +

- {/* 本地工具版本 */} + {/* 创作主题 */}
-

本地工具版本

-
- {loadingTools ? ( - <> - - - - - ) : ( - toolVersions.map((tool) => ( - - )) - )} +

+ {t("支持的创作主题", "支持的创作主题")} +

+
+
+ {CREATIVE_THEMES.map((theme) => ( + + {t(theme, theme)} + + ))} +
+
+
+ + {/* 快速开始 */} +
+

+ {t("3 步开始创作", "3 步开始创作")} +

+
+
    + {QUICK_START_STEPS.map((step) => ( +
  1. {t(step, step)}
  2. + ))} +
+
+
+ + {/* 适合谁 */} +
+

{t("适合谁", "适合谁")}

+
+
    + {TARGET_USERS.map((user) => ( +
  • + + {t(user, user)} +
  • + ))} +
+
+
+ + {/* 可选能力说明 */} +
+

{t("可选能力", "可选能力")}

+
+
+ {t( + "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。", + "团队共享网关用于在局域网内统一暴露兼容 OpenAI/Anthropic 的接口,便于团队复用同一套 Provider 策略与默认模型;单人创作场景可不启用。", + )} +
+
+ {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`。", + )} +
{/* 版权信息 */}
-

Made with love for AI developers

+

+ {t( + "Made with love for creators & builders", + "Made with love for creators & builders", + )} +

2025-2026 ProxyCast

); } - -function ToolVersionItem({ name, version }: { name: string; version: string }) { - const isInstalled = version !== "未安装" && !version.includes("检测"); - - return ( -
- {name} -
- {isInstalled ? ( - - ) : ( - - )} - - {version} - -
-
- ); -} - -function QAItem({ question, answer }: { question: string; answer: string }) { - const [isOpen, setIsOpen] = useState(false); - - return ( -
- - {isOpen && ( -
{answer}
- )} -
- ); -} diff --git a/src/components/settings-v2/system/developer/index.tsx b/src/components/settings-v2/system/developer/index.tsx index 0ef8f27d6..f0187b264 100644 --- a/src/components/settings-v2/system/developer/index.tsx +++ b/src/components/settings-v2/system/developer/index.tsx @@ -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 (
@@ -17,6 +86,20 @@ export function DeveloperSettings() {

开发者工具

+ {/* 消息提示 */} + {message && ( +
+ {message.text} +
+ )} + {/* 组件视图调试 */}
@@ -45,6 +128,46 @@ export function DeveloperSettings() {
)}
+ + {/* 崩溃诊断入口 */} +
+
+
+ +
+
+

崩溃诊断日志(开发协作)

+

+ 用于定位 Windows 闪退与前端异常,包含最近 30 条 FrontendCrash 日志(DSN 自动脱敏) +

+
+
+ +
+ + +
+
); } diff --git a/src/components/settings-v2/system/experimental/index.tsx b/src/components/settings-v2/system/experimental/index.tsx index 9a2e5bda6..2c6b0c7d2 100644 --- a/src/components/settings-v2/system/experimental/index.tsx +++ b/src/components/settings-v2/system/experimental/index.tsx @@ -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(null); const [voiceConfig, setVoiceConfig] = useState(null); + const [crashConfig, setCrashConfig] = useState( + DEFAULT_CRASH_REPORTING_CONFIG, + ); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [diagnosticBusy, setDiagnosticBusy] = useState(false); const [error, setError] = useState(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() { + {/* 崩溃上报 */} +
+
+
+ +
+

崩溃上报(Sentry)

+

+ 收集前端渲染错误与崩溃信息,用于定位 Windows 客诉闪退问题 +

+
+
+
+ {/* 语音输入功能 */} {voiceConfig && (
diff --git a/src/components/settings-v2/system/proxy/index.tsx b/src/components/settings-v2/system/proxy/index.tsx index c21d25f9c..74d7eb9f3 100644 --- a/src/components/settings-v2/system/proxy/index.tsx +++ b/src/components/settings-v2/system/proxy/index.tsx @@ -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(null); @@ -29,13 +18,6 @@ export function ProxySettings() { text: string; } | null>(null); - // API Compatibility Check - const [checking, setChecking] = useState(false); - const [checkResult, setCheckResult] = useState( - null, - ); - const [lastCheckTime, setLastCheckTime] = useState(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 ; - case "partial": - return ; - case "error": - return ; - default: - return null; - } - }; - - const getStatusText = (status: string) => { - switch (status) { - case "healthy": - return "所有模型可用"; - case "partial": - return "部分模型可用"; - case "error": - return "API 不可用"; - default: - return "未知"; - } - }; - if (!config) { return (
@@ -238,147 +180,6 @@ export function ProxySettings() {
- {/* Claude Code 兼容性检测 */} -
-
- -
-

Claude Code 兼容性检测

-

- 检测 API 是否支持 Claude Code 所需的功能 -

-
-
- -
-
-

- 检测项目: -

-
    -
  • 基础对话能力 (basic)
  • -
  • Tool Calls 支持 (tool_call) - Claude Code 核心功能
  • -
-
- -
- - - -
- - {lastCheckTime && ( -

- 最后检测时间: {lastCheckTime.toLocaleString()} -

- )} - - {checkResult && ( -
-
-
- {getStatusIcon(checkResult.overall_status)} - - {checkResult.provider.toUpperCase()} -{" "} - {getStatusText(checkResult.overall_status)} - -
- - {new Date(checkResult.checked_at).toLocaleString()} - -
- -
-

检测结果:

- {checkResult.results.map((r) => ( -
-
- {r.available ? ( - - ) : ( - - )} - - {r.model} - -
-
- {r.status > 0 && HTTP {r.status}} - {r.time_ms}ms - {r.error_type && ( - - {r.error_type} - - )} -
-
- ))} -
- - {checkResult.warnings.length > 0 && ( -
-

警告:

- {checkResult.warnings.map((w, i) => ( -
- - {w} -
- ))} -
- )} -
- )} -
-
); } diff --git a/src/components/settings-v2/system/web-search/index.test.tsx b/src/components/settings-v2/system/web-search/index.test.tsx new file mode 100644 index 000000000..a2f7dd38c --- /dev/null +++ b/src/components/settings-v2/system/web-search/index.test.tsx @@ -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(); + }); + 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(`#${id}`); + if (!node) { + throw new Error(`未找到下拉框: ${id}`); + } + return node; +} + +function findInput(container: HTMLElement, id: string): HTMLInputElement { + const node = container.querySelector(`#${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/"); + }); +}); diff --git a/src/components/settings-v2/system/web-search/index.tsx b/src/components/settings-v2/system/web-search/index.tsx index a2992a120..c7f0e180f 100644 --- a/src/components/settings-v2/system/web-search/index.tsx +++ b/src/components/settings-v2/system/web-search/index.tsx @@ -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(null); const [draftEngine, setDraftEngine] = useState("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 (
@@ -126,6 +171,139 @@ export function WebSearchSettings() {
+
+
+
+ +
+

联网图片搜索

+

+ 配置插图页「图片搜索 → 联网搜索」使用的 Pexels API Key。 +

+
+
+
+ + Pexels {pexelsKeyConfigured ? "已填写" : "未填写"} + + + Pixabay {pixabayKeyConfigured ? "已填写" : "未填写"} + +
+
+ +
+ +
+ + +
+
+ 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" + /> + +
+ +
+

+ 未填写时会回退读取环境变量 PEXELS_API_KEY。 +

+

申请地址:{PEXELS_APPLY_URL}

+

验证路径:插图 → 图片搜索 → 联网搜索。

+
+ +
+ + +
+ + +
+
+ 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" + /> + +
+
+

+ 未填写时会回退读取环境变量 PIXABAY_API_KEY。 +

+

申请地址:{PIXABAY_APPLY_URL}

+

验证路径:插图 → 图片搜索 → Pixabay图库。

+
+
+
+
{hasUnsavedChanges ? "未保存的更改" : "所有更改已保存"} diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx index 6034b10f8..3e5044ff6 100644 --- a/src/components/workspace/WorkbenchPage.tsx +++ b/src/components/workspace/WorkbenchPage.tsx @@ -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({ }} /> - { - if (!creatingContent) { - setCreateContentDialogOpen(open); - if (!open) { - resetCreateContentDialogState(); + mode={selectedCreationMode} + > + { + 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(); + }} + /> +
); } diff --git a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx index 9b18c866a..d1a5c62c9 100644 --- a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx +++ b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx @@ -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(); + }); }); diff --git a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.tsx b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.tsx index 6f91636a5..3568636ac 100644 --- a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.tsx +++ b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.tsx @@ -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 ( @@ -119,12 +218,12 @@ export function WorkbenchCreateContentDialog({
) : (
- {currentCreationIntentFields.map((field) => ( + {safeCreationIntentFields.map((field) => (
{field.options && field.options.length > 0 ? (