Przeglądaj źródła

feat(chat): 章节写作完成或失败时发送系统横幅

接入 tauri-plugin-notification,Windows/macOS 带系统提示音。
计划阶段与用户停止不通知,失败时保留 toast.error。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 tygodni temu
rodzic
commit
164ffa2c22

+ 10 - 0
package-lock.json

@@ -22,6 +22,7 @@
         "@tauri-apps/api": "^2.11.1",
         "@tauri-apps/plugin-dialog": "^2.7.2",
         "@tauri-apps/plugin-http": "^2.5.9",
+        "@tauri-apps/plugin-notification": "^2.3.3",
         "@tauri-apps/plugin-opener": "^2.5.4",
         "@tauri-apps/plugin-store": "^2.4.4",
         "@tauri-apps/plugin-updater": "^2.10.1",
@@ -3239,6 +3240,15 @@
         "@tauri-apps/api": "^2.11.0"
       }
     },
+    "node_modules/@tauri-apps/plugin-notification": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
+      "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@tauri-apps/api": "^2.8.0"
+      }
+    },
     "node_modules/@tauri-apps/plugin-opener": {
       "version": "2.5.4",
       "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

+ 1 - 0
package.json

@@ -31,6 +31,7 @@
     "@tauri-apps/api": "^2.11.1",
     "@tauri-apps/plugin-dialog": "^2.7.2",
     "@tauri-apps/plugin-http": "^2.5.9",
+    "@tauri-apps/plugin-notification": "^2.3.3",
     "@tauri-apps/plugin-opener": "^2.5.4",
     "@tauri-apps/plugin-store": "^2.4.4",
     "@tauri-apps/plugin-updater": "^2.10.1",

+ 72 - 13
src-tauri/Cargo.lock

@@ -683,7 +683,7 @@ version = "3.9.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
 dependencies = [
- "darling 0.23.0",
+ "darling 0.20.11",
  "ident_case",
  "prettyplease",
  "proc-macro2",
@@ -2171,7 +2171,7 @@ dependencies = [
  "libc",
  "option-ext",
  "redox_users",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -2410,7 +2410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -4632,6 +4632,20 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
 [[package]]
 name = "markup5ever"
 version = "0.38.0"
@@ -4812,7 +4826,7 @@ dependencies = [
  "png 0.18.1",
  "serde",
  "thiserror 2.0.20",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -4909,6 +4923,20 @@ dependencies = [
  "windows-sys 0.60.2",
 ]
 
+[[package]]
+name = "notify-rust"
+version = "4.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
 [[package]]
 name = "notify-types"
 version = "2.1.0"
@@ -4924,7 +4952,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -5818,6 +5846,7 @@ dependencies = [
  "tauri-build",
  "tauri-plugin-dialog",
  "tauri-plugin-http",
+ "tauri-plugin-notification",
  "tauri-plugin-opener",
  "tauri-plugin-single-instance",
  "tauri-plugin-store",
@@ -5909,7 +5938,7 @@ dependencies = [
  "once_cell",
  "socket2",
  "tracing",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -6356,7 +6385,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -6413,7 +6442,7 @@ dependencies = [
  "security-framework",
  "security-framework-sys",
  "webpki-root-certs",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -6900,7 +6929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
 dependencies = [
  "libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -7568,6 +7597,25 @@ dependencies = [
  "urlpattern",
 ]
 
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.9.5",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.20",
+ "time",
+ "url",
+]
+
 [[package]]
 name = "tauri-plugin-opener"
 version = "2.5.4"
@@ -7755,6 +7803,17 @@ dependencies = [
  "toml 1.1.4+spec-1.1.0",
 ]
 
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
+dependencies = [
+ "thiserror 2.0.20",
+ "windows 0.61.3",
+ "windows-version",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.27.0"
@@ -7765,7 +7824,7 @@ dependencies = [
  "getrandom 0.4.3",
  "once_cell",
  "rustix 1.1.4",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -8226,7 +8285,7 @@ dependencies = [
  "png 0.18.1",
  "serde",
  "thiserror 2.0.20",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -8270,7 +8329,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
 dependencies = [
  "memoffset",
  "tempfile",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -8716,7 +8775,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]

+ 1 - 0
src-tauri/Cargo.toml

@@ -61,6 +61,7 @@ md-5 = "0.10"
 notify = "8"
 walkdir = "2"
 tauri-plugin-single-instance = "2"
+tauri-plugin-notification = "2"
 keepawake = "0.6"
 
 [target.'cfg(target_os = "macos")'.dependencies]

+ 1 - 0
src-tauri/capabilities/default.json

@@ -12,6 +12,7 @@
     "dialog:default",
     "store:default",
     "updater:default",
+    "notification:default",
     {
       "identifier": "http:default",
       "allow": [

Plik diff jest za duży
+ 0 - 0
src-tauri/gen/schemas/acl-manifests.json


+ 1 - 1
src-tauri/gen/schemas/capabilities.json

@@ -1 +1 @@
-{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-create","core:window:allow-set-title","core:webview:allow-create-webview-window","opener:default","dialog:default","store:default","updater:default",{"identifier":"http:default","allow":[{"url":"http://*"},{"url":"http://*/*"},{"url":"http://*:*"},{"url":"http://*:*/*"},{"url":"http://**"},{"url":"https://*"},{"url":"https://*/*"},{"url":"https://*:*"},{"url":"https://*:*/*"},{"url":"https://**"}]}]}}
+{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-create","core:window:allow-set-title","core:webview:allow-create-webview-window","opener:default","dialog:default","store:default","updater:default","notification:default",{"identifier":"http:default","allow":[{"url":"http://*"},{"url":"http://*/*"},{"url":"http://*:*"},{"url":"http://*:*/*"},{"url":"http://**"},{"url":"https://*"},{"url":"https://*/*"},{"url":"https://*:*"},{"url":"https://*:*/*"},{"url":"https://**"}]}]}}

+ 198 - 0
src-tauri/gen/schemas/desktop-schema.json

@@ -2630,6 +2630,204 @@
           "const": "http:deny-fetch-send",
           "markdownDescription": "Denies the fetch_send command without any pre-configured scope."
         },
+        {
+          "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
+          "type": "string",
+          "const": "notification:default",
+          "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
+        },
+        {
+          "description": "Enables the batch command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-batch",
+          "markdownDescription": "Enables the batch command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-cancel",
+          "markdownDescription": "Enables the cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the check_permissions command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-check-permissions",
+          "markdownDescription": "Enables the check_permissions command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-create-channel",
+          "markdownDescription": "Enables the create_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the delete_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-delete-channel",
+          "markdownDescription": "Enables the delete_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-get-active",
+          "markdownDescription": "Enables the get_active command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_pending command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-get-pending",
+          "markdownDescription": "Enables the get_pending command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_permission_granted command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-is-permission-granted",
+          "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the list_channels command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-list-channels",
+          "markdownDescription": "Enables the list_channels command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the notify command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-notify",
+          "markdownDescription": "Enables the notify command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the permission_state command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-permission-state",
+          "markdownDescription": "Enables the permission_state command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the register_action_types command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-register-action-types",
+          "markdownDescription": "Enables the register_action_types command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-register-listener",
+          "markdownDescription": "Enables the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-remove-active",
+          "markdownDescription": "Enables the remove_active command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the request_permission command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-request-permission",
+          "markdownDescription": "Enables the request_permission command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-show",
+          "markdownDescription": "Enables the show command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the batch command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-batch",
+          "markdownDescription": "Denies the batch command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-cancel",
+          "markdownDescription": "Denies the cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the check_permissions command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-check-permissions",
+          "markdownDescription": "Denies the check_permissions command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-create-channel",
+          "markdownDescription": "Denies the create_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the delete_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-delete-channel",
+          "markdownDescription": "Denies the delete_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-get-active",
+          "markdownDescription": "Denies the get_active command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_pending command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-get-pending",
+          "markdownDescription": "Denies the get_pending command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_permission_granted command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-is-permission-granted",
+          "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the list_channels command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-list-channels",
+          "markdownDescription": "Denies the list_channels command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the notify command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-notify",
+          "markdownDescription": "Denies the notify command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the permission_state command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-permission-state",
+          "markdownDescription": "Denies the permission_state command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the register_action_types command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-register-action-types",
+          "markdownDescription": "Denies the register_action_types command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-register-listener",
+          "markdownDescription": "Denies the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-remove-active",
+          "markdownDescription": "Denies the remove_active command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the request_permission command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-request-permission",
+          "markdownDescription": "Denies the request_permission command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-show",
+          "markdownDescription": "Denies the show command without any pre-configured scope."
+        },
         {
           "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
           "type": "string",

+ 198 - 0
src-tauri/gen/schemas/macOS-schema.json

@@ -2630,6 +2630,204 @@
           "const": "http:deny-fetch-send",
           "markdownDescription": "Denies the fetch_send command without any pre-configured scope."
         },
+        {
+          "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
+          "type": "string",
+          "const": "notification:default",
+          "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
+        },
+        {
+          "description": "Enables the batch command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-batch",
+          "markdownDescription": "Enables the batch command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-cancel",
+          "markdownDescription": "Enables the cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the check_permissions command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-check-permissions",
+          "markdownDescription": "Enables the check_permissions command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the create_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-create-channel",
+          "markdownDescription": "Enables the create_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the delete_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-delete-channel",
+          "markdownDescription": "Enables the delete_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-get-active",
+          "markdownDescription": "Enables the get_active command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the get_pending command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-get-pending",
+          "markdownDescription": "Enables the get_pending command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the is_permission_granted command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-is-permission-granted",
+          "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the list_channels command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-list-channels",
+          "markdownDescription": "Enables the list_channels command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the notify command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-notify",
+          "markdownDescription": "Enables the notify command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the permission_state command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-permission-state",
+          "markdownDescription": "Enables the permission_state command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the register_action_types command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-register-action-types",
+          "markdownDescription": "Enables the register_action_types command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-register-listener",
+          "markdownDescription": "Enables the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the remove_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-remove-active",
+          "markdownDescription": "Enables the remove_active command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the request_permission command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-request-permission",
+          "markdownDescription": "Enables the request_permission command without any pre-configured scope."
+        },
+        {
+          "description": "Enables the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:allow-show",
+          "markdownDescription": "Enables the show command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the batch command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-batch",
+          "markdownDescription": "Denies the batch command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the cancel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-cancel",
+          "markdownDescription": "Denies the cancel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the check_permissions command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-check-permissions",
+          "markdownDescription": "Denies the check_permissions command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the create_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-create-channel",
+          "markdownDescription": "Denies the create_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the delete_channel command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-delete-channel",
+          "markdownDescription": "Denies the delete_channel command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-get-active",
+          "markdownDescription": "Denies the get_active command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the get_pending command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-get-pending",
+          "markdownDescription": "Denies the get_pending command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the is_permission_granted command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-is-permission-granted",
+          "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the list_channels command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-list-channels",
+          "markdownDescription": "Denies the list_channels command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the notify command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-notify",
+          "markdownDescription": "Denies the notify command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the permission_state command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-permission-state",
+          "markdownDescription": "Denies the permission_state command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the register_action_types command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-register-action-types",
+          "markdownDescription": "Denies the register_action_types command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the register_listener command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-register-listener",
+          "markdownDescription": "Denies the register_listener command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the remove_active command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-remove-active",
+          "markdownDescription": "Denies the remove_active command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the request_permission command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-request-permission",
+          "markdownDescription": "Denies the request_permission command without any pre-configured scope."
+        },
+        {
+          "description": "Denies the show command without any pre-configured scope.",
+          "type": "string",
+          "const": "notification:deny-show",
+          "markdownDescription": "Denies the show command without any pre-configured scope."
+        },
         {
           "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
           "type": "string",

+ 1 - 0
src-tauri/src/lib.rs

@@ -43,6 +43,7 @@ pub fn run() {
         .plugin(tauri_plugin_store::Builder::default().build())
         .plugin(tauri_plugin_http::init())
         .plugin(tauri_plugin_updater::Builder::new().build())
+        .plugin(tauri_plugin_notification::init())
         .setup(|app| {
             use tauri::Manager;
             if let Ok(dir) = app.path().resource_dir() {

+ 30 - 1
src/components/chat/chat-panel.tsx

@@ -62,7 +62,7 @@ import { useAgentConfig } from "@/hooks/use-agent-config"
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { executeIngestWrites } from "@/lib/ingest"
-import { routeTask, buildTaskDirective, type TaskRouteResult } from "@/lib/novel/task-router"
+import { routeTask, buildTaskDirective, isChapterWritingIntent, type TaskRouteResult } from "@/lib/novel/task-router"
 import { writeFile, createDirectory, deleteFile } from "@/commands/fs"
 import {
   detectLastGeneratedChapterNumber,
@@ -157,6 +157,10 @@ import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import { ContextUsageRing } from "@/components/chat/context-usage-ring"
 import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service"
 import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service"
+import {
+  ensureSystemNotificationPermission,
+  notifyChapterWritingOutcome,
+} from "@/lib/system-notification"
 
 
 /* spec-test patterns */
@@ -2014,6 +2018,9 @@ export function ChatPanel() {
       }
 
       try {
+        if (isChapterWritingIntent(effectiveTaskRoute?.intent)) {
+          void ensureSystemNotificationPermission()
+        }
         const requiredToolsOnce = resolveRequiredToolsOnce({
           novelMode,
           intent: effectiveTaskRoute?.intent,
@@ -2355,6 +2362,12 @@ export function ChatPanel() {
         if (hasAgentError) {
           useChatStore.getState().failConversationRun(capturedConvId, lastAgentError, runId)
           showRunErrorToast(lastAgentErrorObject ?? new Error(lastAgentError))
+          void notifyChapterWritingOutcome({
+            intent: effectiveTaskRoute?.intent,
+            ok: false,
+            chapterNumber: effectiveTaskRoute?.chapterNumber,
+            error: lastAgentError,
+          })
         } else {
           useChatStore.getState().finishConversationRun(
             capturedConvId,
@@ -2362,6 +2375,7 @@ export function ChatPanel() {
             runId,
           )
         }
+        let chapterWritingPlanOnly = false
         if (!hasAgentError && planExecuteActive) {
           const storeState = useChatStore.getState()
           const lastAssistant = storeState.messages.find(
@@ -2388,6 +2402,7 @@ export function ChatPanel() {
             ?? (planFallbackEligible && fullContent.trim()
               ? { plan: fullContent.trim(), body: "" }
               : null)
+          chapterWritingPlanOnly = Boolean(extracted) || hasMarker
           if (extracted) {
             console.info("[PlanExecute] 计划提取成功,弹出确认对话框", {
               planLength: extracted.plan.length,
@@ -2423,6 +2438,14 @@ export function ChatPanel() {
             })
           }
         }
+        if (!hasAgentError) {
+          void notifyChapterWritingOutcome({
+            intent: effectiveTaskRoute?.intent,
+            planOnly: chapterWritingPlanOnly,
+            ok: true,
+            chapterNumber: effectiveTaskRoute?.chapterNumber,
+          })
+        }
       } catch (error) {
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
@@ -2442,6 +2465,12 @@ export function ChatPanel() {
         })
         useChatStore.getState().failConversationRun(capturedConvId, errorMessage, runId)
         showRunErrorToast(resolvedError)
+        void notifyChapterWritingOutcome({
+          intent: effectiveTaskRoute?.intent,
+          ok: false,
+          chapterNumber: effectiveTaskRoute?.chapterNumber,
+          error: errorMessage,
+        })
       }
     },
     [

+ 11 - 1
src/lib/novel/task-router.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { routeTask } from "./task-router"
+import { isChapterWritingIntent, routeTask } from "./task-router"
 
 describe("routeTask chapter generation", () => {
   it("routes continue-next-chapter requests into chapter generation flow", () => {
@@ -86,4 +86,14 @@ describe("routeTask chapter generation", () => {
     expect(route.intent).toBe("write_chapter")
     expect(route.chapterNumber).toBe(5)
   })
+
+  it("identifies chapter writing intents", () => {
+    expect(isChapterWritingIntent("write_chapter")).toBe(true)
+    expect(isChapterWritingIntent("continue_chapter")).toBe(true)
+    expect(isChapterWritingIntent("rewrite_chapter")).toBe(true)
+    expect(isChapterWritingIntent("polish_chapter")).toBe(true)
+    expect(isChapterWritingIntent("generate_outline")).toBe(false)
+    expect(isChapterWritingIntent("general_chat")).toBe(false)
+    expect(isChapterWritingIntent(null)).toBe(false)
+  })
 })

+ 4 - 0
src/lib/novel/task-router.ts

@@ -433,6 +433,10 @@ const CHAPTER_WRITING_INTENTS = new Set<NovelTaskIntent>([
   "polish_chapter",
 ])
 
+export function isChapterWritingIntent(intent?: string | null): boolean {
+  return Boolean(intent && CHAPTER_WRITING_INTENTS.has(intent as NovelTaskIntent))
+}
+
 export function buildTaskDirective(route: TaskRouteResult): string {
   const directives: Record<NovelTaskIntent, string> = {
     write_chapter: "用户要求生成新章节。请根据上下文包中的大纲、人物状态和伏笔状态,生成完整的章节正文。注意保持人设一致,结尾留有钩子。",

+ 168 - 0
src/lib/system-notification.spec.ts

@@ -0,0 +1,168 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { isChapterWritingIntent } from "@/lib/novel/task-router"
+import {
+  buildChapterWritingNotification,
+  ensureSystemNotificationPermission,
+  notifyChapterWritingOutcome,
+  resolveNotificationSound,
+  shouldNotifyChapterWriting,
+  type SystemNotificationBindings,
+} from "./system-notification"
+
+function bindings(overrides: Partial<SystemNotificationBindings> = {}): SystemNotificationBindings {
+  return {
+    isTauri: () => true,
+    isPermissionGranted: async () => true,
+    requestPermission: async () => "granted",
+    sendNotification: vi.fn(),
+    userAgent: () => "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
+    platform: () => "Win32",
+    warn: vi.fn(),
+    ...overrides,
+  }
+}
+
+describe("resolveNotificationSound", () => {
+  it("uses Ping on macOS", () => {
+    expect(resolveNotificationSound(
+      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+      "MacIntel",
+    )).toBe("Ping")
+  })
+
+  it("uses Default on Windows", () => {
+    expect(resolveNotificationSound(
+      "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
+      "Win32",
+    )).toBe("Default")
+  })
+})
+
+describe("shouldNotifyChapterWriting", () => {
+  it("allows chapter writing intents", () => {
+    expect(isChapterWritingIntent("write_chapter")).toBe(true)
+    expect(isChapterWritingIntent("continue_chapter")).toBe(true)
+    expect(isChapterWritingIntent("rewrite_chapter")).toBe(true)
+    expect(isChapterWritingIntent("polish_chapter")).toBe(true)
+    expect(shouldNotifyChapterWriting({ intent: "write_chapter" })).toBe(true)
+  })
+
+  it("skips non-writing intents and plan-only turns", () => {
+    expect(isChapterWritingIntent("generate_outline")).toBe(false)
+    expect(isChapterWritingIntent("general_chat")).toBe(false)
+    expect(shouldNotifyChapterWriting({ intent: "generate_outline" })).toBe(false)
+    expect(shouldNotifyChapterWriting({ intent: "write_chapter", planOnly: true })).toBe(false)
+  })
+})
+
+describe("buildChapterWritingNotification", () => {
+  it("includes chapter number on success", () => {
+    expect(buildChapterWritingNotification({ ok: true, chapterNumber: 8 })).toEqual({
+      title: "章节写作完成",
+      body: "第 8 章已完成",
+    })
+  })
+
+  it("truncates failure body", () => {
+    const error = "x".repeat(140)
+    const result = buildChapterWritingNotification({ ok: false, error })
+    expect(result.title).toBe("章节写作失败")
+    expect(result.body).toHaveLength(121)
+    expect(result.body.endsWith("…")).toBe(true)
+  })
+})
+
+describe("ensureSystemNotificationPermission", () => {
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  it("is a no-op outside Tauri", async () => {
+    const testBindings = bindings({
+      isTauri: () => false,
+      isPermissionGranted: vi.fn(async () => true),
+    })
+    await expect(ensureSystemNotificationPermission(testBindings)).resolves.toBe(false)
+    expect(testBindings.isPermissionGranted).not.toHaveBeenCalled()
+  })
+
+  it("requests permission when not granted", async () => {
+    const testBindings = bindings({
+      isPermissionGranted: vi.fn(async () => false),
+      requestPermission: vi.fn(async () => "granted"),
+    })
+    await expect(ensureSystemNotificationPermission(testBindings)).resolves.toBe(true)
+    expect(testBindings.requestPermission).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe("notifyChapterWritingOutcome", () => {
+  it("does not send outside Tauri", async () => {
+    const testBindings = bindings({ isTauri: () => false })
+    await notifyChapterWritingOutcome({ intent: "write_chapter", ok: true }, testBindings)
+    expect(testBindings.sendNotification).not.toHaveBeenCalled()
+  })
+
+  it("does not send when permission is denied", async () => {
+    const testBindings = bindings({
+      isPermissionGranted: async () => false,
+      requestPermission: async () => "denied",
+    })
+    await notifyChapterWritingOutcome({ intent: "write_chapter", ok: true }, testBindings)
+    expect(testBindings.sendNotification).not.toHaveBeenCalled()
+  })
+
+  it("sends a Windows notification with Default sound", async () => {
+    const testBindings = bindings()
+    await notifyChapterWritingOutcome({
+      intent: "write_chapter",
+      ok: true,
+      chapterNumber: 3,
+    }, testBindings)
+    expect(testBindings.sendNotification).toHaveBeenCalledWith({
+      title: "章节写作完成",
+      body: "第 3 章已完成",
+      sound: "Default",
+    })
+  })
+
+  it("sends a macOS notification with Ping sound", async () => {
+    const testBindings = bindings({
+      userAgent: () => "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
+      platform: () => "MacIntel",
+    })
+    await notifyChapterWritingOutcome({
+      intent: "continue_chapter",
+      ok: false,
+      error: "扩写后字数不足",
+    }, testBindings)
+    expect(testBindings.sendNotification).toHaveBeenCalledWith({
+      title: "章节写作失败",
+      body: "扩写后字数不足",
+      sound: "Ping",
+    })
+  })
+
+  it("does not send for plan-only chapter writing", async () => {
+    const testBindings = bindings()
+    await notifyChapterWritingOutcome({
+      intent: "write_chapter",
+      planOnly: true,
+      ok: true,
+    }, testBindings)
+    expect(testBindings.sendNotification).not.toHaveBeenCalled()
+  })
+
+  it("warns and does not throw when sending fails", async () => {
+    const testBindings = bindings({
+      sendNotification: () => {
+        throw new Error("plugin missing")
+      },
+    })
+    await expect(notifyChapterWritingOutcome({
+      intent: "write_chapter",
+      ok: true,
+    }, testBindings)).resolves.toBeUndefined()
+    expect(testBindings.warn).toHaveBeenCalled()
+  })
+})

+ 114 - 0
src/lib/system-notification.ts

@@ -0,0 +1,114 @@
+import { isTauri } from "@/lib/platform"
+import { isChapterWritingIntent } from "@/lib/novel/task-router"
+
+const FAILURE_BODY_MAX_CHARS = 120
+
+export interface SystemNotificationBindings {
+  isTauri: () => boolean
+  isPermissionGranted: () => Promise<boolean>
+  requestPermission: () => Promise<string>
+  sendNotification: (options: { title: string; body: string; sound: string }) => void
+  userAgent: () => string
+  platform: () => string
+  warn: (message: string, error: unknown) => void
+}
+
+const defaultBindings: SystemNotificationBindings = {
+  isTauri,
+  isPermissionGranted: async () => {
+    const { isPermissionGranted } = await import("@tauri-apps/plugin-notification")
+    return isPermissionGranted()
+  },
+  requestPermission: async () => {
+    const { requestPermission } = await import("@tauri-apps/plugin-notification")
+    return requestPermission()
+  },
+  sendNotification: (options) => {
+    void import("@tauri-apps/plugin-notification").then(({ sendNotification }) => {
+      sendNotification(options)
+    }).catch((error) => {
+      defaultBindings.warn("[system-notification] 发送失败", error)
+    })
+  },
+  userAgent: () => (typeof navigator === "undefined" ? "" : navigator.userAgent),
+  platform: () => (typeof navigator === "undefined" ? "" : navigator.platform),
+  warn: (message, error) => console.warn(message, error),
+}
+
+export function resolveNotificationSound(
+  userAgent = defaultBindings.userAgent(),
+  platform = defaultBindings.platform(),
+): string {
+  if (/Mac|iPhone|iPad/i.test(userAgent) || /^Mac/i.test(platform)) {
+    return "Ping"
+  }
+  return "Default"
+}
+
+export function shouldNotifyChapterWriting(input: {
+  intent?: string | null
+  planOnly?: boolean
+}): boolean {
+  return isChapterWritingIntent(input.intent) && !input.planOnly
+}
+
+export function buildChapterWritingNotification(input: {
+  ok: boolean
+  chapterNumber?: number
+  error?: string
+}): { title: string; body: string } {
+  if (input.ok) {
+    return {
+      title: "章节写作完成",
+      body: typeof input.chapterNumber === "number" && input.chapterNumber > 0
+        ? `第 ${input.chapterNumber} 章已完成`
+        : "章节已完成",
+    }
+  }
+
+  const error = (input.error ?? "生成失败").trim() || "生成失败"
+  return {
+    title: "章节写作失败",
+    body: error.length > FAILURE_BODY_MAX_CHARS
+      ? `${error.slice(0, FAILURE_BODY_MAX_CHARS)}…`
+      : error,
+  }
+}
+
+export async function ensureSystemNotificationPermission(
+  bindings: SystemNotificationBindings = defaultBindings,
+): Promise<boolean> {
+  if (!bindings.isTauri()) return false
+  try {
+    if (await bindings.isPermissionGranted()) return true
+    return (await bindings.requestPermission()) === "granted"
+  } catch (error) {
+    bindings.warn("[system-notification] 请求权限失败", error)
+    return false
+  }
+}
+
+export async function notifyChapterWritingOutcome(
+  input: {
+    intent?: string | null
+    planOnly?: boolean
+    ok: boolean
+    chapterNumber?: number
+    error?: string
+  },
+  bindings: SystemNotificationBindings = defaultBindings,
+): Promise<void> {
+  if (!shouldNotifyChapterWriting(input)) return
+  if (!bindings.isTauri()) return
+
+  try {
+    if (!(await ensureSystemNotificationPermission(bindings))) return
+    const payload = buildChapterWritingNotification(input)
+    bindings.sendNotification({
+      ...payload,
+      sound: resolveNotificationSound(bindings.userAgent(), bindings.platform()),
+    })
+  } catch (error) {
+    bindings.warn("[system-notification] 发送失败", error)
+  }
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików