Compare commits
43 Commits
bd6a41fe6e
...
40a97a7655
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a97a7655 | |||
| 7c2e3b23c1 | |||
| 1ffa023f3d | |||
| 61bad4fa28 | |||
| bdf3f96491 | |||
| bb8b3c6c59 | |||
| 96e554ce22 | |||
| 0a8db6504a | |||
| 78b48d871b | |||
| 698d981962 | |||
| 0c7c5e6068 | |||
| f4b65f6c2e | |||
| 59f07bf060 | |||
| a9703c3a1f | |||
| ed8e2fc155 | |||
| 612de4cd7c | |||
| 60b73377f6 | |||
| 030ddb668a | |||
| 0fb5af50c7 | |||
| 8ccdce282c | |||
| 5337c7d5e3 | |||
| d38d1af68d | |||
| 057fd0994e | |||
| da886fa1c8 | |||
| e8468c5bc6 | |||
| 36a8391626 | |||
| a5a4fc887a | |||
| 280baeaee0 | |||
| 25d65652cd | |||
| 2b8b30ef41 | |||
| 2fe1fe57c8 | |||
| d1d3871a88 | |||
| 2a13f45650 | |||
| 83cee61476 | |||
| 82059bac4a | |||
| cf7fe867ee | |||
| 5d1749a58f | |||
| 566bbcb394 | |||
| eda2a5f887 | |||
| c9b6e28433 | |||
| 72e57c6232 | |||
| cdb227f999 | |||
| d2cada97cd |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
# docker build 排除(docker context = repo 根,仅 df-relay 编译需 workspace 源码)
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
*.db
|
||||
*.log
|
||||
apps/df-miniapp/node_modules
|
||||
apps/df-miniapp/dist
|
||||
src-tauri/target
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# uni-app 编译产物(微信小程序/H5 打包输出)
|
||||
unpackage/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
325
Cargo.lock
generated
325
Cargo.lock
generated
@@ -248,6 +248,64 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -721,6 +779,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -778,6 +842,7 @@ dependencies = [
|
||||
"df-nodes",
|
||||
"df-project",
|
||||
"df-storage",
|
||||
"df-tunnel",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"futures",
|
||||
@@ -794,6 +859,11 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tree-sitter",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -897,6 +967,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-relay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-storage"
|
||||
version = "0.1.0"
|
||||
@@ -912,6 +998,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-tunnel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "df-types"
|
||||
version = "0.1.0"
|
||||
@@ -1842,6 +1943,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -1856,6 +1963,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -2259,6 +2367,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2382,6 +2496,21 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
@@ -2521,6 +2650,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -3743,6 +3881,7 @@ version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -3750,6 +3889,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3876,6 +4026,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3986,6 +4145,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -4536,6 +4701,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -4640,6 +4814,32 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4786,6 +4986,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4829,6 +5030,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4852,6 +5054,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4876,12 +5108,99 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde_json",
|
||||
"streaming-iterator",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-javascript"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-typescript"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5019,6 +5338,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
||||
21
apps/df-miniapp/index.html
Normal file
21
apps/df-miniapp/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no" />
|
||||
<title>DevFlow Mini</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.documentElement.style.fontSize = window.innerWidth / 20 + 'px'
|
||||
})
|
||||
</script>
|
||||
<link rel="stylesheet" href="<%= BASE_URL %>static/index.<%= VUE_APP_INDEX_CSS_HASH %>.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>本应用需 JavaScript 启用。</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
13924
apps/df-miniapp/package-lock.json
generated
Normal file
13924
apps/df-miniapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
apps/df-miniapp/package.json
Normal file
35
apps/df-miniapp/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "df-miniapp",
|
||||
"version": "0.1.0",
|
||||
"description": "DevFlow 微信小程序(uni-app Vue3)—— 远程 AI Chat 操作桌面端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
|
||||
"marked": "^18.0.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-i18n": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.0",
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vue/runtime-core": "^3.4.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
31
apps/df-miniapp/src/App.vue
Normal file
31
apps/df-miniapp/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
|
||||
// 应用启动:连接 relay。useAiChat 单例管理 WS 生命周期 + 状态。
|
||||
const aiChat = useAiChat()
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('[DevFlow Mini] 应用启动')
|
||||
aiChat.connect()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
|
||||
aiChat.resumeBackground()
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
|
||||
console.log('[DevFlow Mini] 应用进入后台')
|
||||
aiChat.pauseBackground()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
page {
|
||||
background-color: $uni-bg-color;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
</style>
|
||||
586
apps/df-miniapp/src/api/ws.ts
Normal file
586
apps/df-miniapp/src/api/ws.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
|
||||
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
|
||||
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
|
||||
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
|
||||
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
|
||||
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
|
||||
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
|
||||
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
|
||||
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
|
||||
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
|
||||
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
|
||||
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
|
||||
*
|
||||
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
|
||||
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
/** 入站事件回调(Event/Control 消息分派) */
|
||||
export interface WsHandlers {
|
||||
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
|
||||
onEvent: (msg: BroadcastMessage) => void
|
||||
/** 收到 Control 消息(心跳响应 / 在线状态等) */
|
||||
onControl: (msg: ControlMessage) => void
|
||||
/** 状态变更(连接 / 断开 / 重连中) */
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
|
||||
interface QueuedCmd {
|
||||
cmd: MiniCommand
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
|
||||
const HANDSHAKE_TIMEOUT_MS = 12000
|
||||
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
|
||||
const CONNECT_TIMEOUT_MS = 8000
|
||||
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
|
||||
const PENDING_MAX = 50
|
||||
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
|
||||
const PENDING_TTL_MS = 60000
|
||||
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
|
||||
const HEARTBEAT_DEAD_FACTOR = 2
|
||||
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
|
||||
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
|
||||
*/
|
||||
class WsClient {
|
||||
private socket: UniApp.SocketTask | null = null
|
||||
private handlers: WsHandlers | null = null
|
||||
private status: WsStatus = 'disconnected'
|
||||
/** 握手是否已完成(收到首个广播或握手成功信号) */
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
/**
|
||||
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
||||
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
||||
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
|
||||
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
|
||||
*/
|
||||
private pendingQueue: QueuedCmd[] = []
|
||||
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
|
||||
private lastInboundAt = 0
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
this.handlers = h
|
||||
}
|
||||
|
||||
/** 读取当前状态 */
|
||||
getStatus(): WsStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/** 主动连接 */
|
||||
connect(): void {
|
||||
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
|
||||
this.reconnectAttempts = 0
|
||||
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
||||
this.reconnectNow()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.reconnectAttempts = 0
|
||||
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
|
||||
*
|
||||
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
|
||||
* - disconnected:onClose 已触发,直接重连;
|
||||
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
|
||||
*
|
||||
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
|
||||
*/
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.manualClose) return
|
||||
if (this.status === 'disconnected' || this.status === 'reconnecting') {
|
||||
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
|
||||
*
|
||||
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
|
||||
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
|
||||
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
|
||||
*/
|
||||
pauseBackground(): void {
|
||||
this.stopHeartbeat()
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
|
||||
*/
|
||||
resumeBackground(): void {
|
||||
if (this.status === 'connected' && this.handshaked) {
|
||||
this.startHeartbeat()
|
||||
} else {
|
||||
this.resumeIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
||||
if (!this.socket || !this.handshaked) {
|
||||
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
||||
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
||||
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
|
||||
if (this.manualClose) {
|
||||
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
this.enqueuePending(cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(cmd)
|
||||
this.socket.send({ data: text })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WsClient] send 失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
|
||||
private enqueuePending(cmd: MiniCommand): void {
|
||||
if (this.pendingQueue.length >= PENDING_MAX) {
|
||||
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
|
||||
this.pendingQueue.shift()
|
||||
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
|
||||
}
|
||||
this.pendingQueue.push({ cmd, ts: Date.now() })
|
||||
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
|
||||
*
|
||||
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
|
||||
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
|
||||
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
|
||||
*/
|
||||
private flushPendingQueue(): void {
|
||||
if (this.pendingQueue.length === 0) return
|
||||
const now = Date.now()
|
||||
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const item = this.pendingQueue.shift()!
|
||||
if (now - item.ts > PENDING_TTL_MS) {
|
||||
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (this.socket) {
|
||||
this.socket.send({ data: JSON.stringify(item.cmd) })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
|
||||
|
||||
/**
|
||||
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
|
||||
*
|
||||
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
|
||||
* 统一经此,保证:
|
||||
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
|
||||
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
|
||||
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
|
||||
*/
|
||||
private reconnectNow(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.cleanup()
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
let task: UniApp.SocketTask
|
||||
try {
|
||||
task = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[WsClient] connectSocket 异常', e)
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
this.socket = task
|
||||
|
||||
task.onOpen(() => this.handleOpen())
|
||||
task.onMessage((res) => this.handleMessage(res))
|
||||
task.onClose((res) => this.handleClose(res))
|
||||
task.onError((res) => this.handleError(res))
|
||||
|
||||
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
|
||||
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (this.status === 'connecting') {
|
||||
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
|
||||
private handleOpen(): void {
|
||||
// onOpen 到达,取消连接超时
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
|
||||
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (!this.handshaked) {
|
||||
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
|
||||
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
|
||||
if (typeof res.data !== 'string') {
|
||||
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
|
||||
return
|
||||
}
|
||||
const text = res.data
|
||||
if (!text) return
|
||||
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
|
||||
this.lastInboundAt = Date.now()
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
this.handleHandshakeResponse(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
|
||||
let msg: BroadcastMessage
|
||||
try {
|
||||
msg = JSON.parse(text) as BroadcastMessage
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
|
||||
if (msg.kind === 'event') {
|
||||
this.handlers?.onEvent(msg)
|
||||
} else if (msg.kind === 'control') {
|
||||
this.handlers?.onControl(msg.payload as ControlMessage)
|
||||
}
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
|
||||
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
|
||||
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
|
||||
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
|
||||
let parsedOk = false
|
||||
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
|
||||
parsedOk = true
|
||||
} catch (e) {
|
||||
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
|
||||
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
|
||||
}
|
||||
|
||||
if (parsedOk && parsed) {
|
||||
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
|
||||
if (parsed.error !== undefined) {
|
||||
console.error('[WsClient] 握手失败', parsed.error, text)
|
||||
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
|
||||
this.clearHandshakeTimer()
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
|
||||
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
|
||||
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
|
||||
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
|
||||
// device 离线则无响应,文案保持「已连接中继」诚实表述。
|
||||
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
|
||||
this.flushPendingQueue()
|
||||
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
|
||||
return
|
||||
}
|
||||
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
|
||||
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败(includes 兜底)', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
this.clearHandshakeTimer()
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
|
||||
this.clearHandshakeTimer()
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
this.send({ cmd: 'list_conversations', args: {} })
|
||||
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
|
||||
this.flushPendingQueue()
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
private handleClose(res: { code: number; reason: string }): void {
|
||||
console.log('[WsClient] 连接关闭', res.code, res.reason)
|
||||
this.cleanup()
|
||||
if (!this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
|
||||
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
|
||||
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
|
||||
if (this.status === 'connecting' || this.status === 'handshaking') {
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
// 连接成功初始化活性时间(首次心跳检查不立即判死)
|
||||
this.lastInboundAt = Date.now()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
|
||||
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
|
||||
const now = Date.now()
|
||||
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
|
||||
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
|
||||
this.cleanup()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(ping) })
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 心跳发送失败', e)
|
||||
}
|
||||
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
|
||||
void cfg
|
||||
}, cfg.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
|
||||
*
|
||||
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
|
||||
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
|
||||
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
|
||||
*
|
||||
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
|
||||
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
||||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
||||
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const exp = Math.min(attempt, 6)
|
||||
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
|
||||
const jitter = 0.5 + Math.random() * 0.5
|
||||
const delay = Math.round(base * jitter)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.reconnectNow()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清握手超时定时器(握手成功/失败时调) */
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
|
||||
*
|
||||
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
|
||||
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
|
||||
*/
|
||||
private destroySocket(task: UniApp.SocketTask): void {
|
||||
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
|
||||
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
|
||||
try {
|
||||
task.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
|
||||
*
|
||||
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearHandshakeTimer()
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
this.destroySocket(this.socket)
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
this.pendingQueue = []
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
this.status = status
|
||||
this.handlers?.onStatus(status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(miniapp 进程内一个连接) */
|
||||
export const wsClient = new WsClient()
|
||||
36
apps/df-miniapp/src/components/MdView/MdView.vue
Normal file
36
apps/df-miniapp/src/components/MdView/MdView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<!-- rich-text 小程序原生组件(无子组件依赖,稳)。marked md→html → rich-text :nodes 渲染。
|
||||
alpha 根因已查实(uni-app 2026 alpha 致 tap 失效),rich-text 非元凶,降级后正常用。 -->
|
||||
<rich-text :nodes="html" :selectable="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
|
||||
defineOptions({ name: 'MdView' })
|
||||
|
||||
/**
|
||||
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
|
||||
*
|
||||
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
|
||||
* - breaks:true 聊天换行(单换行转 <br>)
|
||||
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
|
||||
* - parse 失败回退原文
|
||||
* - 空内容返空(rich-text 不渲染)
|
||||
*
|
||||
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
|
||||
*/
|
||||
const props = defineProps<{ content: string }>()
|
||||
|
||||
const html = computed(() => {
|
||||
const src = props.content || ''
|
||||
if (!src) return ''
|
||||
try {
|
||||
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
|
||||
} catch {
|
||||
return src
|
||||
}
|
||||
})
|
||||
</script>
|
||||
133
apps/df-miniapp/src/components/MentionInput/MentionInput.vue
Normal file
133
apps/df-miniapp/src/components/MentionInput/MentionInput.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<!-- @/ 联想弹层(MVP)。
|
||||
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入。
|
||||
/file /web /task /memory + @file @web @task 四类技能占位。
|
||||
渲染对齐 chat/index.vue 的 mp-weixin 稳定风格(原生 view/text + 绝对定位浮层)。 -->
|
||||
<view v-if="visible" class="mention-mask" @tap="onClose">
|
||||
<view class="mention-pop" @tap.stop>
|
||||
<view class="mention-head">
|
||||
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
|
||||
<text class="mention-hint">MVP 占位 · 点击插入</text>
|
||||
</view>
|
||||
<scroll-view class="mention-list" scroll-y>
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="mention-item"
|
||||
@tap="onSelect(item)"
|
||||
>
|
||||
<text class="mention-item-label">{{ item.label }}</text>
|
||||
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MentionInput 联想弹层组件(MVP)。
|
||||
*
|
||||
* 设计:
|
||||
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
|
||||
* - 不查后端/不调路由:items 由父组件固定注入
|
||||
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
|
||||
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
|
||||
*
|
||||
* props:
|
||||
* - visible:是否显示
|
||||
* - trigger:触发类型(`/` 技能 / `@` 提及)
|
||||
* - items:占位项列表 {label, insert}
|
||||
* emits:
|
||||
* - select(insert):插入文本
|
||||
* - close():关闭弹层
|
||||
*/
|
||||
defineOptions({ name: 'MentionInput' })
|
||||
|
||||
interface MentionItem {
|
||||
label: string
|
||||
insert: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
trigger: '/' | '@'
|
||||
items: MentionItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', insert: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
function onSelect(item: MentionItem): void {
|
||||
emit('select', item.insert)
|
||||
}
|
||||
|
||||
function onClose(): void {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
.mention-pop {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 56px;
|
||||
max-height: 260px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #262626;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
.mention-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mention-hint {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mention-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mention-item-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mention-item-insert {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
1118
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
1118
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
File diff suppressed because it is too large
Load Diff
54
apps/df-miniapp/src/config.ts
Normal file
54
apps/df-miniapp/src/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填)
|
||||
*
|
||||
* 这些值在真实部署前是占位:
|
||||
* - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址
|
||||
* - DEVICE_ID:用户首次配置桌面端时生成的设备 ID
|
||||
* - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token)
|
||||
*/
|
||||
|
||||
export interface MiniappConfig {
|
||||
/** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */
|
||||
relayHost: string
|
||||
/** 配对绑定的设备 ID */
|
||||
deviceId: string
|
||||
/** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */
|
||||
token: string
|
||||
/** 心跳间隔(ms) */
|
||||
heartbeatInterval: number
|
||||
/** 重连初始间隔(ms,指数退避) */
|
||||
reconnectBaseDelay: number
|
||||
/** 重连最大间隔(ms) */
|
||||
reconnectMaxDelay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置(占位值,需替换)
|
||||
*
|
||||
* 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。
|
||||
* MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接,
|
||||
* 这符合预期,提示用户配置)。
|
||||
*/
|
||||
export const defaultConfig: MiniappConfig = {
|
||||
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
|
||||
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
|
||||
relayHost: 'wss://u-work.1216.top/ws/miniapp',
|
||||
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
|
||||
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
|
||||
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
|
||||
token: 'devflow-relay-default-token',
|
||||
heartbeatInterval: 30000,
|
||||
reconnectBaseDelay: 1000,
|
||||
reconnectMaxDelay: 30000,
|
||||
}
|
||||
|
||||
/** 单例配置(miniapp 内存配置,后续接 storage 持久化) */
|
||||
let _config: MiniappConfig = { ...defaultConfig }
|
||||
|
||||
export function getConfig(): MiniappConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setConfig(patch: Partial<MiniappConfig>): void {
|
||||
_config = { ..._config, ...patch }
|
||||
}
|
||||
13
apps/df-miniapp/src/main.ts
Normal file
13
apps/df-miniapp/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
/**
|
||||
* uni-app 入口:createSSRApp 规范(uni-app Vue3 必需,非 createApp)。
|
||||
* 桌面端 main.ts 用 createApp(Tauri 浏览器环境),小程序用 createSSRApp 对齐 uni 跨端。
|
||||
*/
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
53
apps/df-miniapp/src/manifest.json
Normal file
53
apps/df-miniapp/src/manifest.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "DevFlow",
|
||||
"appid": "PLACEHOLDER",
|
||||
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": 100,
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "PLACEHOLDER",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"minified": true,
|
||||
"postcss": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"permission": {},
|
||||
"requiredBackgroundModes": [],
|
||||
"requiredPrivateInfos": []
|
||||
},
|
||||
"h5": {
|
||||
"title": "DevFlow Mini",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 8081,
|
||||
"https": false
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
41
apps/df-miniapp/src/pages.json
Normal file
41
apps/df-miniapp/src/pages.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 对话",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/conversations/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会话列表",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "DevFlow",
|
||||
"navigationBarBackgroundColor": "#1a1a1a",
|
||||
"backgroundColor": "#0f0f0f",
|
||||
"backgroundTextStyle": "light"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#4a9eff",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/chat/index",
|
||||
"text": "对话"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/conversations/index",
|
||||
"text": "会话"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
993
apps/df-miniapp/src/pages/chat/index.vue
Normal file
993
apps/df-miniapp/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,993 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { styleMarkdown } from '@/utils/mdRenderer'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { MentionSpan } from '@/types/relay'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events'
|
||||
|
||||
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
|
||||
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
|
||||
// MdView/MentionInput 源保留备用(工具修复后可拆回)。
|
||||
/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */
|
||||
const mdCache = new Map<string, string>()
|
||||
function renderMd(src: string): string {
|
||||
if (!src) return ''
|
||||
const cached = mdCache.get(src)
|
||||
if (cached !== undefined) return cached
|
||||
let html: string
|
||||
try {
|
||||
html = marked.parse(src, { breaks: true, async: false }) as string
|
||||
html = styleMarkdown(html) // P1-G:代码块/链接/表格 inline style(rich-text 不认 class)
|
||||
} catch {
|
||||
html = src
|
||||
}
|
||||
mdCache.set(src, html)
|
||||
return html
|
||||
}
|
||||
|
||||
const ai = useAiChat()
|
||||
const inputText = ref('')
|
||||
|
||||
// —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) ——
|
||||
// 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。
|
||||
const mentionVisible = ref(false)
|
||||
const mentionTrigger = ref<'skill' | 'entity'>('skill')
|
||||
|
||||
// —— pendingSkill(`/<skill>` 选中后 chip 展示,handleSend 时透传 ai.send skill 参数) ——
|
||||
// 选中技能后清 inputText 并置 chip,后续用户输入技能参数;发送时 skill.name 透传后端注入 SKILL.md。
|
||||
// 类型与 useAiChat.skills 元素一致(对齐 relay.ts SkillInfo)。
|
||||
type SkillInfoLike = { name: string; description: string; argument_hint?: string }
|
||||
const pendingSkill = ref<SkillInfoLike | null>(null)
|
||||
|
||||
// —— pendingMentionSpans(@项目/@任务/@灵感 选中后累积,handleSend 时透传 ai.send spans 参数) ——
|
||||
// 每条对应 inputText 中一段 `[类型:名]` 文本区间(start/length 为字符偏移)。
|
||||
// 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。
|
||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
|
||||
// —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 ——
|
||||
/** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */
|
||||
const scrollAnchor = ref('')
|
||||
function scrollToBottom(): void {
|
||||
scrollAnchor.value = ''
|
||||
// setTimeout(0) 等新消息 DOM 渲染完(mp-weixin nextTick 兼容)
|
||||
setTimeout(() => { scrollAnchor.value = 'list-bottom-anchor' }, 0)
|
||||
}
|
||||
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 流式增量高频,throttle 防滚底抖动 */
|
||||
function throttledScrollToBottom(): void {
|
||||
if (scrollTimer) return
|
||||
scrollTimer = setTimeout(() => {
|
||||
scrollTimer = null
|
||||
scrollToBottom()
|
||||
}, 120)
|
||||
}
|
||||
// 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底
|
||||
watch(() => ai.messages.length, () => scrollToBottom())
|
||||
watch(() => ai.currentText.value, throttledScrollToBottom)
|
||||
// P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击)
|
||||
watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false })
|
||||
/**
|
||||
* P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。
|
||||
* TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。
|
||||
*/
|
||||
const MAX_RENDER_MSGS = 200
|
||||
const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS))
|
||||
|
||||
/**
|
||||
* 输入变化时检测联想触发条件。
|
||||
*
|
||||
* 两类触发:
|
||||
* - 技能(`skill`):inputText 以 `/` 开头,且尚未选 pendingSkill(已选则不再触发,
|
||||
* 因 chip 已在输入栏,用户输入的是技能参数)→ listSkills() 拉技能 + 显技能浮层。
|
||||
* - 实体(`entity`):末尾连续非空白段恰好为单字符 `@`(避免匹配 url/邮箱)→
|
||||
* listEntities() 拉实体 + 显实体浮层。
|
||||
*
|
||||
* 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。
|
||||
* 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。
|
||||
*/
|
||||
function detectTrigger(): void {
|
||||
const text = inputText.value
|
||||
if (!text) {
|
||||
mentionVisible.value = false
|
||||
return
|
||||
}
|
||||
// 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。
|
||||
if (text.startsWith('/') && !pendingSkill.value) {
|
||||
mentionTrigger.value = 'skill'
|
||||
mentionVisible.value = true
|
||||
ai.listSkills()
|
||||
return
|
||||
}
|
||||
// 实体触发:末尾连续非空白段恰好单字符 @
|
||||
const match = text.match(/([^\s]+)\s*$/)
|
||||
const last = match ? match[1] : ''
|
||||
if (last === '@') {
|
||||
mentionTrigger.value = 'entity'
|
||||
mentionVisible.value = true
|
||||
ai.listEntities()
|
||||
return
|
||||
}
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */
|
||||
function onInput(): void {
|
||||
detectTrigger()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中技能(技能浮层):置 pendingSkill chip + 清 inputText。
|
||||
*
|
||||
* 清 inputText 因技能模式语义是「选技能后输参数」,选完即重置输入框等用户输参数文本。
|
||||
* 技能参数提示(argument_hint,如 "<url>")随 chip 展示,引导用户输入。
|
||||
* 不透传 skill 到 inputText(避免 `/技能名` 文本污染参数输入),skill 仅经 handleSend 透传。
|
||||
*/
|
||||
function onSkillSelect(skill: SkillInfoLike): void {
|
||||
pendingSkill.value = skill
|
||||
inputText.value = ''
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中实体(实体浮层):替换末尾 `@` 为 `[类型:名]` 文本 + 累积 MentionSpan。
|
||||
*
|
||||
* span.start/length:实体插入文本(inputText 中 `[类型:名]` 段)的字符偏移,
|
||||
* 后端据 start/length 在原文中切出区间注入对应实体上下文。
|
||||
* kind/refId/label 对齐后端 MentionSpanDto:project/task/idea + id + 展示名。
|
||||
* 连续 @ 多个实体时累积(每次 push 一条),发送时整体透传。
|
||||
*/
|
||||
function onEntitySelect(
|
||||
entity: ProjectRecord | TaskRecord | IdeaRecord,
|
||||
kind: 'project' | 'task' | 'idea',
|
||||
): void {
|
||||
const text = inputText.value
|
||||
// 去末尾触发字符 `@`(detectTrigger 已保证末段是 @,这里兜底 replace 防御)
|
||||
const stripped = text.replace(/@\s*$/, '')
|
||||
// 类型标签中文映射(对齐桌面端 chip 展示习惯:项目/任务/灵感)
|
||||
const kindLabel = kind === 'project' ? '项目' : kind === 'task' ? '任务' : '灵感'
|
||||
// 名:project 用 name,task/idea 用 title
|
||||
const name = (entity as ProjectRecord).name || (entity as TaskRecord | IdeaRecord).title
|
||||
const insertText = `[${kindLabel}:${name}]`
|
||||
const start = stripped.length
|
||||
inputText.value = stripped + insertText
|
||||
// 累积 mention span(字符偏移,后端透传不解释单位)
|
||||
pendingMentionSpans.value.push({
|
||||
start,
|
||||
length: insertText.length,
|
||||
kind,
|
||||
refId: entity.id,
|
||||
label: name,
|
||||
})
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/** 清除已选技能 chip(handleSend 后或用户点 × 清除) */
|
||||
function clearPendingSkill(): void {
|
||||
pendingSkill.value = null
|
||||
}
|
||||
|
||||
/** 关闭弹层(点遮罩) */
|
||||
function onMentionClose(): void {
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送:对齐 useAiChat.send(text, skill?, spans?) 新签名。
|
||||
*
|
||||
* - skill:已选 pendingSkill 时透传 name(后端注入 SKILL.md);否则 undefined(普通对话)。
|
||||
* - spans:有 pendingMentionSpans 时透传(后端 resolve 实体上下文);否则 undefined。
|
||||
* - 发送后清 inputText + pendingSkill + pendingMentionSpans,复位待下一次输入。
|
||||
*
|
||||
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
|
||||
*/
|
||||
function handleSend(): void {
|
||||
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value)
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
console.log('[v2:send] 空文本,忽略')
|
||||
return
|
||||
}
|
||||
if (ai.generating.value) {
|
||||
// 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑
|
||||
uni.showToast({
|
||||
title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候',
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
mentionVisible.value = false
|
||||
const skill = pendingSkill.value ? pendingSkill.value.name : undefined
|
||||
const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined
|
||||
ai.send(text, skill, spans)
|
||||
inputText.value = ''
|
||||
pendingSkill.value = null
|
||||
pendingMentionSpans.value = []
|
||||
}
|
||||
|
||||
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
|
||||
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
|
||||
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
|
||||
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
|
||||
|
||||
// 高危工具白名单(approve 前二次确认,对齐桌面 ToolCard.vue HIGH_RISK_TOOLS)
|
||||
const HIGH_RISK_TOOLS = new Set<string>([
|
||||
'delete_task',
|
||||
'delete_project',
|
||||
'restore_project',
|
||||
'purge_project',
|
||||
'delete_file',
|
||||
'run_command',
|
||||
'http_request',
|
||||
])
|
||||
|
||||
// 工具状态 → 中文文案 + 颜色 class(渲染状态点/标签)
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
running: { label: '执行中', cls: 'st-running' },
|
||||
completed: { label: '已完成', cls: 'st-completed' },
|
||||
failed: { label: '失败', cls: 'st-failed' },
|
||||
pending_approval: { label: '待审批', cls: 'st-pending' },
|
||||
rejected: { label: '已拒绝', cls: 'st-rejected' },
|
||||
}
|
||||
|
||||
/** 截断长字符串(参数值/结果展示防撑爆气泡) */
|
||||
function truncate(s: string, max = 120): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s
|
||||
}
|
||||
|
||||
/** 格式化工具参数为 [{key,val}] 行数组(args 任意结构,最多 4 项防长) */
|
||||
function formatArgs(args: unknown): { key: string; val: string }[] {
|
||||
if (!args || typeof args !== 'object') return []
|
||||
const obj = args as Record<string, unknown>
|
||||
return Object.entries(obj)
|
||||
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
val: truncate(typeof v === 'string' ? v : JSON.stringify(v)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 格式化执行结果摘要(completed/failed 展示,result 可能任意结构) */
|
||||
function formatResult(result: unknown): string {
|
||||
if (result === undefined || result === null) return ''
|
||||
if (typeof result === 'string') return truncate(result)
|
||||
try {
|
||||
return truncate(JSON.stringify(result))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。
|
||||
*
|
||||
* 后端工具挂起审批时往消息流写占位 assistant message,content 形如
|
||||
* 「需要用户审批,等待确认__PENDING__:call_xxx」。桌面端识别占位转工具审批卡片;
|
||||
* miniapp 不识别会原样渲染成气泡(暴露 __PENDING__ 技术标识)。此处识别后隐藏文本,
|
||||
* 审批 UI 由消息内联 m.toolCalls 工具卡片单独渲染(单一渲染源,不重复)。
|
||||
*/
|
||||
const PENDING_MARKER = '__PENDING__:'
|
||||
const PENDING_PLACEHOLDER_LEGACY = '需要用户审批,等待确认'
|
||||
function isPendingPlaceholder(content: string | undefined | null): boolean {
|
||||
if (!content) return false
|
||||
if (content.includes(PENDING_MARKER)) return true
|
||||
return content === PENDING_PLACEHOLDER_LEGACY
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别纯 JSON 工具结果 message(content 是工具执行结果 JSON,如 list_tasks 的
|
||||
* {"has_more":false,"items":[],...})。弱化为灰小字折叠,不占主气泡,不刺眼。
|
||||
*/
|
||||
function isToolResultJson(content: string | undefined | null): boolean {
|
||||
if (!content) return false
|
||||
const t = content.trim()
|
||||
if (!(t.startsWith('{') || t.startsWith('['))) return false
|
||||
try {
|
||||
JSON.parse(t)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批通过(risk 类):高危工具先 uni.showModal 二次确认 */
|
||||
function onApprove(tc: AiToolCallInfo): void {
|
||||
if (HIGH_RISK_TOOLS.has(tc.name)) {
|
||||
uni.showModal({
|
||||
title: '高危操作确认',
|
||||
content: `「${tc.name}」属高危操作,确认批准执行?`,
|
||||
confirmColor: '#e85a4f',
|
||||
success: (r) => {
|
||||
if (r.confirm) ai.approve(tc.id, true)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
ai.approve(tc.id, true)
|
||||
}
|
||||
|
||||
/** 审批拒绝(risk 类) */
|
||||
function onReject(tc: AiToolCallInfo): void {
|
||||
ai.approve(tc.id, false)
|
||||
}
|
||||
|
||||
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
|
||||
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
|
||||
ai.authorizeDir(tc.id, decision)
|
||||
}
|
||||
|
||||
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
connecting: '连接中…',
|
||||
handshaking: '握手中…',
|
||||
reconnecting: '重连中…·点此立即重连',
|
||||
disconnected: '已断开·点此重连',
|
||||
}
|
||||
const statusText = computed(() => {
|
||||
if (ai.isWsConnected.value) {
|
||||
return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继'
|
||||
}
|
||||
return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value
|
||||
})
|
||||
const canManualReconnect = computed(
|
||||
() => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting',
|
||||
)
|
||||
function onStatusTap(): void {
|
||||
if (canManualReconnect.value) ai.resumeIfDisconnected()
|
||||
}
|
||||
|
||||
/** 停止生成(wires F9 终态兜底;生成中 send 禁用无停止入口,用户无法中断) */
|
||||
function onStop(): void {
|
||||
ai.stop()
|
||||
}
|
||||
|
||||
/** 继续循环(P1-E:MaxRounds 挂起后再跑一轮) */
|
||||
function onContinueLoop(): void {
|
||||
ai.continueLoop()
|
||||
}
|
||||
|
||||
/** 停止循环(P1-E:MaxRounds 挂起后彻底终止多轮 agent) */
|
||||
function onStopLoop(): void {
|
||||
ai.stopLoop()
|
||||
}
|
||||
|
||||
/** 重发最后一条 user 消息(P1-F:regenerate 入口) */
|
||||
function onRegenerate(): void {
|
||||
ai.regenerate()
|
||||
}
|
||||
|
||||
/** 长按消息复制全文(P1-F:assistant 复制 markdown 源,user 复制原文) */
|
||||
function onCopyMessage(m: { content?: string }): void {
|
||||
if (!m.content) return
|
||||
uni.setClipboardData({
|
||||
data: m.content,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 状态栏 + 待审批徽标(F21:断开/重连中可点状态栏手动重连) -->
|
||||
<view class="top">
|
||||
<text class="st" :class="{ 'st-tap': canManualReconnect }" @tap="onStatusTap">{{ statusText }}</text>
|
||||
<view class="top-right">
|
||||
<text v-if="ai.tokenUsage.value" class="token-usage">🪙 {{ ai.tokenUsage.value.total }}</text>
|
||||
<view v-if="ai.pendingApprovals.length > 0" class="pending-badge">
|
||||
<text>⏳ {{ ai.pendingApprovals.length }} 待审批</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 达最大轮次面板(P1-E:继续/停止循环,对齐桌面 MaxRounds) -->
|
||||
<view v-if="ai.maxRoundsActive.value" class="maxrounds-panel">
|
||||
<text class="maxrounds-text">⚠ 已达最大轮次</text>
|
||||
<view class="maxrounds-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onContinueLoop">继续一轮</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onStopLoop">停止</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 待审批面板(从 pendingApprovals 渲染,与 messages 解耦:req3 重连 load_messages 替换 messages 不影响审批卡) -->
|
||||
<view v-if="ai.pendingApprovals.length > 0" class="pending-panel">
|
||||
<view class="pending-panel-head">
|
||||
<text>⏳ {{ ai.pendingApprovals.length }} 项待审批</text>
|
||||
</view>
|
||||
<view v-for="p in ai.pendingApprovals" :key="p.id" class="tool-card">
|
||||
<view class="tool-head">
|
||||
<text class="tool-name">🔧 {{ p.name }}</text>
|
||||
<text class="tool-status st-pending">待审批</text>
|
||||
</view>
|
||||
<view v-if="formatArgs(p.args).length > 0" class="tool-args">
|
||||
<view v-for="a in formatArgs(p.args)" :key="a.key" class="tool-arg">
|
||||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||||
<text class="tool-arg-val">{{ a.val }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
|
||||
<text>📁 {{ p.dir }}</text>
|
||||
</view>
|
||||
<view v-if="p.reason" class="tool-reason">
|
||||
<text>⚠ {{ p.reason }}</text>
|
||||
</view>
|
||||
<!-- risk 类:批准/拒绝 -->
|
||||
<view v-if="p.kind !== 'path'" class="approval-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
|
||||
</view>
|
||||
<!-- path 类:本次/永久/拒绝 -->
|
||||
<view v-else class="approval-actions">
|
||||
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
|
||||
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
|
||||
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表(scroll-view 标准滚动,纯 text 渲染) -->
|
||||
<scroll-view class="list" scroll-y :scroll-into-view="scrollAnchor" :scroll-with-animation="true">
|
||||
<view v-if="ai.messages.length === 0" class="empty">
|
||||
<text>暂无消息</text>
|
||||
</view>
|
||||
<view v-for="m in visibleMessages" :key="m.id" class="msg" :class="m.role" @longpress="onCopyMessage(m)">
|
||||
<text v-if="m.role === 'user'" user-select>{{ m.content }}</text>
|
||||
<template v-else>
|
||||
<!-- 后端审批占位 message(「需要…__PENDING__:call_xxx」)隐藏文本,审批走工具卡片 -->
|
||||
<rich-text
|
||||
v-if="m.content && !isPendingPlaceholder(m.content) && !isToolResultJson(m.content)"
|
||||
:nodes="renderMd(m.content)"
|
||||
selectable
|
||||
/>
|
||||
<!-- 纯 JSON 工具结果弱化(灰小字折叠,不占主气泡) -->
|
||||
<view v-else-if="m.content && isToolResultJson(m.content)" class="msg-toolresult">
|
||||
<text>📋 {{ m.content }}</text>
|
||||
</view>
|
||||
<!-- 占位 message(isPendingPlaceholder)不渲染文本,仅下方工具卡片承载审批 UI -->
|
||||
<!-- 工具调用卡片(对齐桌面 ToolCard,assistant 消息内联渲染状态 + 审批按钮) -->
|
||||
<view v-for="tc in m.toolCalls || []" :key="tc.id" class="tool-card">
|
||||
<view class="tool-head">
|
||||
<text class="tool-name">🔧 {{ tc.name }}</text>
|
||||
<text class="tool-status" :class="(STATUS_META[tc.status] || {}).cls">
|
||||
{{ (STATUS_META[tc.status] || {}).label || tc.status }}
|
||||
</text>
|
||||
</view>
|
||||
<!-- 参数 -->
|
||||
<view v-if="formatArgs(tc.args).length > 0" class="tool-args">
|
||||
<view v-for="a in formatArgs(tc.args)" :key="a.key" class="tool-arg">
|
||||
<text class="tool-arg-key">{{ a.key }}:</text>
|
||||
<text class="tool-arg-val">{{ a.val }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- path 授权待授权目录 -->
|
||||
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
|
||||
<text>📁 {{ tc.dir }}</text>
|
||||
</view>
|
||||
<!-- 风险说明 -->
|
||||
<view v-if="tc.reason" class="tool-reason">
|
||||
<text>⚠ {{ tc.reason }}</text>
|
||||
</view>
|
||||
<!-- 审批按钮已移至顶部 pendingApprovals 面板(与 messages 解耦,req3 重连恢复) -->
|
||||
<!-- 执行结果摘要(completed/failed) -->
|
||||
<view
|
||||
v-if="(tc.status === 'completed' || tc.status === 'failed') && formatResult(tc.result)"
|
||||
class="tool-result"
|
||||
>
|
||||
<text>{{ formatResult(tc.result) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<!-- 流式生成中的当前文本 -->
|
||||
<view v-if="ai.generating.value && ai.currentText.value" class="msg assistant">
|
||||
<text user-select>{{ ai.currentText.value }}</text>
|
||||
</view>
|
||||
<!-- P1-12 滚底锚点(scroll-into-view 目标) -->
|
||||
<view id="list-bottom-anchor"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 联想弹层(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) -->
|
||||
<view v-if="mentionVisible && !ai.generating.value" class="mention-mask" @tap="onMentionClose">
|
||||
<view class="mention-pop" @tap.stop>
|
||||
<view class="mention-head">
|
||||
<text class="mention-title">{{ mentionTrigger === 'skill' ? '技能' : '提及' }}</text>
|
||||
<text class="mention-hint">点击选中</text>
|
||||
</view>
|
||||
<scroll-view class="mention-list" scroll-y>
|
||||
<!-- 技能列表(`/` 触发,name/description/argument_hint 展示) -->
|
||||
<template v-if="mentionTrigger === 'skill'">
|
||||
<view v-if="ai.skills.length === 0" class="mention-empty">
|
||||
<text>加载中或无技能...</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(s, idx) in ai.skills"
|
||||
:key="'sk-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onSkillSelect(s)"
|
||||
>
|
||||
<view class="mention-item-main">
|
||||
<text class="mention-item-label">/{{ s.name }}</text>
|
||||
<text class="mention-item-source">{{ s.source }}</text>
|
||||
</view>
|
||||
<text class="mention-item-desc">{{ s.description }}</text>
|
||||
<text v-if="s.argument_hint" class="mention-item-insert">参数:{{ s.argument_hint }}</text>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 实体列表(`@` 触发,按 项目/任务/灵感 分组) -->
|
||||
<template v-else>
|
||||
<view v-if="ai.entities.projects.length === 0 && ai.entities.tasks.length === 0 && ai.entities.ideas.length === 0" class="mention-empty">
|
||||
<text>加载中或无数据...</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.projects.length > 0" class="mention-group-title">
|
||||
<text>项目</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(p, idx) in ai.entities.projects"
|
||||
:key="'p-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(p, 'project')"
|
||||
>
|
||||
<text class="mention-item-label">{{ p.name }}</text>
|
||||
<text class="mention-item-desc">{{ p.description }}</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.tasks.length > 0" class="mention-group-title">
|
||||
<text>任务</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(t, idx) in ai.entities.tasks"
|
||||
:key="'t-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(t, 'task')"
|
||||
>
|
||||
<text class="mention-item-label">{{ t.title }}</text>
|
||||
<text class="mention-item-desc">{{ t.description }}</text>
|
||||
</view>
|
||||
<view v-if="ai.entities.ideas.length > 0" class="mention-group-title">
|
||||
<text>灵感</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(i, idx) in ai.entities.ideas"
|
||||
:key="'i-' + idx"
|
||||
class="mention-item"
|
||||
@tap="onEntitySelect(i, 'idea')"
|
||||
>
|
||||
<text class="mention-item-label">{{ i.title }}</text>
|
||||
<text class="mention-item-desc">{{ i.description }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 输入 + 发送(原生 button @tap) -->
|
||||
<view class="bar">
|
||||
<!-- pendingSkill chip(选中技能后展示名+参数提示,× 清除) -->
|
||||
<view v-if="pendingSkill" class="skill-chip">
|
||||
<text class="skill-chip-name">/{{ pendingSkill.name }}</text>
|
||||
<text v-if="pendingSkill.argument_hint" class="skill-chip-hint">{{ pendingSkill.argument_hint }}</text>
|
||||
<text class="skill-chip-close" @tap="clearPendingSkill">×</text>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
class="ipt"
|
||||
:placeholder="ai.generating.value ? '生成中,完成后发送…' : pendingSkill ? '输入技能参数' : '输入消息(输入 / 或 @ 触发联想)'"
|
||||
:auto-height="true"
|
||||
:adjust-position="true"
|
||||
confirm-type="send"
|
||||
@confirm="handleSend"
|
||||
@input="onInput"
|
||||
/>
|
||||
<!-- 重发最后一条 user(P1-F,非生成中且有消息时显) -->
|
||||
<button
|
||||
v-if="!ai.generating.value && ai.messages.length > 0"
|
||||
class="regen-btn"
|
||||
@tap="onRegenerate"
|
||||
>
|
||||
重发
|
||||
</button>
|
||||
<!-- 生成中显停止按钮(wires F9),否则发送 -->
|
||||
<button v-if="ai.generating.value" class="send-btn stop-btn" @tap="onStop">停止</button>
|
||||
<button v-else class="send-btn" @tap="handleSend">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #0f0f0f;
|
||||
}
|
||||
.top {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
.st {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.st-tap {
|
||||
color: #4a9eff;
|
||||
}
|
||||
.top-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.token-usage {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
.empty text {
|
||||
color: #666666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
.msg text {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg.user {
|
||||
background-color: #4a9eff;
|
||||
}
|
||||
.msg.user text {
|
||||
color: #ffffff;
|
||||
}
|
||||
.msg.system text {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 8px 12px;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
.ipt {
|
||||
flex: 1;
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
min-height: 36px;
|
||||
max-height: 120px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.send-btn {
|
||||
margin-left: 8px;
|
||||
background-color: #4a9eff;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.send-btn.stop-btn {
|
||||
background-color: #5a2a2a;
|
||||
color: #e85a4f;
|
||||
}
|
||||
.regen-btn {
|
||||
margin-left: 8px;
|
||||
background-color: #2a2a2a;
|
||||
color: #c0c0c0;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* @/ 联想弹层(内联自 MentionInput,绕过工具组件解析 bug) */
|
||||
.mention-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
.mention-pop {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 56px;
|
||||
max-height: 260px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mention-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #262626;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
.mention-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mention-hint {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mention-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-item-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mention-item-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mention-item-source {
|
||||
color: #777777;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.mention-item-desc {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mention-item-insert {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mention-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.mention-empty text {
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mention-group-title {
|
||||
padding: 6px 12px;
|
||||
background-color: #232323;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mention-group-title text {
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* pendingSkill chip(选中技能后展示名+参数提示+× 清除) */
|
||||
.skill-chip {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
margin-right: 6px;
|
||||
background-color: #1f3a5f;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.skill-chip-name {
|
||||
color: #4a9eff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.skill-chip-hint {
|
||||
color: #7799bb;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.skill-chip-close {
|
||||
color: #4a9eff;
|
||||
font-size: 16px;
|
||||
margin-left: 6px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* 工具卡片 + 审批 UI(对齐桌面 ToolCard,深色风格) */
|
||||
.pending-badge {
|
||||
flex-shrink: 0;
|
||||
margin: 0 8px;
|
||||
padding: 2px 8px;
|
||||
background-color: #3a2f1f;
|
||||
border: 1px solid #f0c75e;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pending-badge text {
|
||||
color: #f0c75e;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 待审批面板(从 pendingApprovals 渲染,与 messages 解耦避重连竞态) */
|
||||
.pending-panel {
|
||||
flex-shrink: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 8px;
|
||||
background-color: #1a1a1a;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.pending-panel-head {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.pending-panel-head text {
|
||||
color: #f0c75e;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 达最大轮次面板(P1-E:继续/停止循环) */
|
||||
.maxrounds-panel {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #2a2218;
|
||||
border-bottom: 1px solid #3a2f1f;
|
||||
}
|
||||
.maxrounds-text {
|
||||
color: #f0c75e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.maxrounds-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.tool-card {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background-color: #1f1f1f;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tool-head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tool-name {
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tool-status {
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.tool-status.st-running {
|
||||
background-color: #2a3a5a;
|
||||
color: #6bb6ff;
|
||||
}
|
||||
.tool-status.st-completed {
|
||||
background-color: #1f3a2a;
|
||||
color: #6bd99a;
|
||||
}
|
||||
.tool-status.st-failed {
|
||||
background-color: #3a1f1f;
|
||||
color: #e85a4f;
|
||||
}
|
||||
.tool-status.st-pending {
|
||||
background-color: #3a2f1f;
|
||||
color: #f0c75e;
|
||||
}
|
||||
.tool-status.st-rejected {
|
||||
background-color: #2a2a2a;
|
||||
color: #999999;
|
||||
}
|
||||
.tool-args {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.tool-arg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tool-arg-key {
|
||||
color: #7799bb;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.tool-arg-val {
|
||||
color: #c0c0c0;
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.tool-dir {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tool-dir text {
|
||||
color: #6bd99a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tool-reason {
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
background-color: #2a2218;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tool-reason text {
|
||||
color: #f0c75e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.approval-btn {
|
||||
margin-right: 6px;
|
||||
padding: 0 12px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.approval-btn.approve {
|
||||
background-color: #2a5a3a;
|
||||
color: #6bd99a;
|
||||
}
|
||||
.approval-btn.always {
|
||||
background-color: #2a3a5a;
|
||||
color: #6bb6ff;
|
||||
}
|
||||
.approval-btn.reject {
|
||||
background-color: #5a2a2a;
|
||||
color: #e85a4f;
|
||||
margin-right: 0;
|
||||
}
|
||||
.tool-result {
|
||||
margin-top: 6px;
|
||||
padding: 4px 6px;
|
||||
background-color: #181818;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tool-result text {
|
||||
color: #999999;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 纯 JSON 工具结果 message 弱化(灰小字折叠,避免刺眼占用主气泡) */
|
||||
.msg-toolresult {
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
background-color: #181818;
|
||||
border-radius: 6px;
|
||||
max-height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-toolresult text {
|
||||
color: #777777;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
189
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
189
apps/df-miniapp/src/pages/conversations/index.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useAiChat } from '@/composables/useAiChat'
|
||||
import type { Conversation } from '@/types/events'
|
||||
|
||||
const ai = useAiChat()
|
||||
|
||||
/** 点击会话项切换 */
|
||||
function handleSelect(convId: string): void {
|
||||
ai.switchConversation(convId)
|
||||
// 切换后跳回对话页
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 新建会话 */
|
||||
function handleNew(): void {
|
||||
ai.newConversation()
|
||||
uni.switchTab({ url: '/pages/chat/index' })
|
||||
}
|
||||
|
||||
/** 长按会话项重命名(对齐桌面端改会话名) */
|
||||
function handleRename(conv: Conversation): void {
|
||||
uni.showModal({
|
||||
title: '重命名会话',
|
||||
editable: true,
|
||||
placeholderText: '输入新名称',
|
||||
content: conv.title || '',
|
||||
success: (r) => {
|
||||
if (r.confirm && r.content) {
|
||||
const newTitle = r.content.trim()
|
||||
if (newTitle && newTitle !== conv.title) {
|
||||
ai.renameConversation(conv.id, newTitle)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
|
||||
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
|
||||
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
|
||||
onPullDownRefresh(() => {
|
||||
ai.refreshConversations()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
/** 格式化时间戳为可读字符串 */
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="conv-page">
|
||||
<!-- 顶部新建按钮 -->
|
||||
<view class="new-bar" @click="handleNew">
|
||||
<text class="new-icon">+</text>
|
||||
<text class="new-text">新建会话</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<scroll-view class="conv-list" scroll-y>
|
||||
<view v-if="ai.conversations.length === 0" class="empty-hint">
|
||||
<text>暂无历史会话</text>
|
||||
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="conv in ai.conversations"
|
||||
:key="conv.id"
|
||||
class="conv-item"
|
||||
:class="{ active: conv.id === ai.activeConversationId.value }"
|
||||
@click="handleSelect(conv.id)"
|
||||
@longpress="handleRename(conv)"
|
||||
>
|
||||
<view class="conv-main">
|
||||
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
|
||||
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
|
||||
</view>
|
||||
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
||||
</view>
|
||||
<view v-if="ai.conversations.length > 0" class="list-hint">
|
||||
<text>长按会话可重命名</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conv-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: $uni-bg-color;
|
||||
}
|
||||
|
||||
.new-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
background-color: $uni-bg-color-dark;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
font-size: 40rpx;
|
||||
color: $uni-color-primary;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.new-text {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-color-primary;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 200rpx 24rpx;
|
||||
text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-base;
|
||||
}
|
||||
.empty-sub {
|
||||
margin-top: 12rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid $uni-border-color;
|
||||
&.active {
|
||||
background-color: $uni-bg-color-light;
|
||||
border-left: 6rpx solid $uni-color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: $uni-font-size-base;
|
||||
color: $uni-text-color;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
margin-top: 8rpx;
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-grey;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: $uni-font-size-sm;
|
||||
color: $uni-text-color-disable;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
.list-hint {
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.list-hint text {
|
||||
color: $uni-text-color-disable;
|
||||
font-size: $uni-font-size-sm;
|
||||
}
|
||||
</style>
|
||||
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
7
apps/df-miniapp/src/shims-uni.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
193
apps/df-miniapp/src/types/events.ts
Normal file
193
apps/df-miniapp/src/types/events.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* AiChatEvent 类型镜像(对齐 DevFlow 后端 Rust 定义)
|
||||
*
|
||||
* 类型证据:
|
||||
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
|
||||
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
|
||||
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
|
||||
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
|
||||
*
|
||||
* 注意:TS 联合类型 discriminator 名为 `type`,与 Rust #[serde(tag = "type")] 一致。
|
||||
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
|
||||
*/
|
||||
|
||||
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
|
||||
import type { SkillInfo } from './relay'
|
||||
|
||||
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
|
||||
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||
|
||||
/** 会话生命周期状态(对齐 Rust ConvState,L2 统一状态机,snake_case 序列化) */
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
/**
|
||||
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
|
||||
*
|
||||
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
|
||||
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
|
||||
*
|
||||
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
|
||||
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
|
||||
*/
|
||||
export type AiChatEvent =
|
||||
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallStarted'; id: string; name: string; args: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolCallCompleted'; id: string; result: unknown; conversation_id?: string | null }
|
||||
| { type: 'AiToolAutoApproved'; id: string; tool: string; dir: string; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiApprovalRequired'
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
reason: string
|
||||
diff?: string | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiApprovalResult'; id: string; approved: boolean; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiCompleted'
|
||||
total_tokens: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
incomplete?: boolean | null
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
|
||||
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
|
||||
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
|
||||
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
||||
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
||||
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
||||
| {
|
||||
type: 'AiDirAuthRequired'
|
||||
id: string
|
||||
tool: string
|
||||
path: string
|
||||
dir: string
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||
| { type: 'AiCompressed'; conversation_id?: string | null; summary: string }
|
||||
| {
|
||||
type: 'AiHelpRequired'
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversation_id?: string | null
|
||||
}
|
||||
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
|
||||
| { type: 'AiConversationList'; conversations: Conversation[] }
|
||||
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
|
||||
| { type: 'AiSkillList'; skills: SkillInfo[] }
|
||||
| {
|
||||
type: 'AiEntityList'
|
||||
projects: ProjectRecord[]
|
||||
tasks: TaskRecord[]
|
||||
ideas: IdeaRecord[]
|
||||
}
|
||||
|
||||
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
|
||||
export type AiChatEventType = AiChatEvent['type']
|
||||
|
||||
/** 会话信息(对齐桌面端 list_conversations 返回结构,字段名 camelCase 兼容桌面 IPC 约定) */
|
||||
export interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
/** 最近一条消息摘要(可选) */
|
||||
lastMessage?: string
|
||||
/** 最近更新时间戳(ms) */
|
||||
updatedAt?: number
|
||||
/** 创建时间(ms) */
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
/** 本地渲染消息气泡(对齐桌面端 AiMessage 结构,简化为 miniapp 必需字段) */
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: number
|
||||
isError?: boolean
|
||||
/** 工具调用列表(对齐 AiToolCallInfo) */
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
/** 不完整标记(AiCompleted{incomplete:true} 对应系统提示气泡) */
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
/** 工具调用信息(对齐桌面 useAiEvents.ts:399 的 AiToolCallInfo) */
|
||||
export interface AiToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
result?: unknown
|
||||
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
||||
reason?: string
|
||||
diff?: string
|
||||
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
|
||||
kind?: 'risk' | 'path'
|
||||
dir?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
||||
export type PendingApproval = AiToolCallInfo
|
||||
|
||||
/** Token 用量(对齐桌面端 state.lastTokenUsage) */
|
||||
export interface TokenUsage {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
|
||||
// ============================================================
|
||||
//
|
||||
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
|
||||
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
|
||||
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
|
||||
|
||||
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
|
||||
export interface ProjectRecord {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
idea_id: string | null
|
||||
/** 绑定的本地代码目录绝对路径(null=未绑定) */
|
||||
path: string | null
|
||||
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
|
||||
stack: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
|
||||
priority: number
|
||||
branch_name: string | null
|
||||
assignee: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
|
||||
export interface IdeaRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
priority: number
|
||||
score: number | null
|
||||
tags: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
204
apps/df-miniapp/src/types/relay.ts
Normal file
204
apps/df-miniapp/src/types/relay.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/)
|
||||
*
|
||||
* 类型证据:
|
||||
* - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41
|
||||
* (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control')
|
||||
* - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts)
|
||||
* - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token)
|
||||
* kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp'
|
||||
* - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点)
|
||||
*
|
||||
* 关键协议点(relay.rs:332-365 handle_inbound_text):
|
||||
* relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。
|
||||
* 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。
|
||||
* 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage
|
||||
* 再从 payload 取 AiChatEvent。
|
||||
*/
|
||||
|
||||
/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */
|
||||
export type ClientKind = 'device' | 'miniapp'
|
||||
|
||||
/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */
|
||||
export type MessageKind = 'event' | 'command' | 'control'
|
||||
|
||||
/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */
|
||||
export type PresenceState = 'online' | 'offline'
|
||||
|
||||
/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */
|
||||
export type ConnId = number
|
||||
|
||||
/**
|
||||
* 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62)
|
||||
*
|
||||
* miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。
|
||||
*/
|
||||
export interface BroadcastMessage {
|
||||
/** 配对绑定的设备 ID */
|
||||
device_id: string
|
||||
/** 消息语义 */
|
||||
kind: MessageKind
|
||||
/** 消息来源连接 ID(relay 分配,u64) */
|
||||
source: ConnId
|
||||
/** 来源客户端类型 */
|
||||
from: ClientKind
|
||||
/** 载荷(原始 JSON,relay 透传不解析 —— Event 时为 AiChatEvent) */
|
||||
payload: unknown
|
||||
/** 时间戳(ms) */
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53)
|
||||
*
|
||||
* 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。
|
||||
* kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。
|
||||
*/
|
||||
export interface Hello {
|
||||
/** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */
|
||||
kind: 'miniapp' | 'device'
|
||||
/** 配对绑定的设备 ID(用户首次配置时生成) */
|
||||
device_id: string
|
||||
/** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind")
|
||||
*
|
||||
* miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。
|
||||
*/
|
||||
export type ControlMessage =
|
||||
| { control_kind: 'pair'; device_id: string; token: string }
|
||||
| { control_kind: 'heartbeat'; device_id: string }
|
||||
| { control_kind: 'presence'; device_id: string; state: PresenceState }
|
||||
| { control_kind: 'ping' }
|
||||
| { control_kind: 'pong' }
|
||||
|
||||
/**
|
||||
* 小程序 → 桌面端 Command 协议(miniapp 发起操作)
|
||||
*
|
||||
* 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。
|
||||
* 字段:
|
||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||
* - args:命令参数(对齐各 command 的参数)
|
||||
*
|
||||
* 路由表全覆盖(14 条):
|
||||
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
|
||||
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
|
||||
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
||||
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
||||
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
||||
*/
|
||||
export interface MiniCommand {
|
||||
/** Tauri command 名 */
|
||||
cmd: string
|
||||
/** 命令参数(原始对象,对齐各 command 参数签名) */
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** send_message 命令参数(对齐 src-tauri ai_chat_send command) */
|
||||
export interface SendMessageArgs {
|
||||
message: string
|
||||
conversation_id?: string | null
|
||||
model_override?: string | null
|
||||
/**
|
||||
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
|
||||
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
|
||||
*/
|
||||
skill?: string | null
|
||||
/**
|
||||
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
|
||||
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
|
||||
* null/undefined/空=无 mention(纯文本消息)。
|
||||
*/
|
||||
mention_spans?: MentionSpan[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户消息内 mention 区间的元数据
|
||||
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
|
||||
*
|
||||
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
|
||||
* 前端按 {start, length} 在原文中切出区间替换为 chip。
|
||||
*/
|
||||
export interface MentionSpan {
|
||||
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
|
||||
start: number
|
||||
/** 区间长度 */
|
||||
length: number
|
||||
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
|
||||
kind: 'project' | 'task' | 'idea' | 'skill'
|
||||
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
|
||||
refId: string
|
||||
/** chip 展示文本(项目名/任务标题/技能名) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
|
||||
* src-tauri/src/commands/ai/skills.rs:19-34)。
|
||||
*
|
||||
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
|
||||
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
|
||||
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
|
||||
*/
|
||||
export interface SkillInfo {
|
||||
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
|
||||
name: string
|
||||
/** 技能描述(浮层副标题) */
|
||||
description: string
|
||||
/** 参数提示(可选,如 "<url>") */
|
||||
argument_hint?: string
|
||||
/** skill | command | plugin(来源分类) */
|
||||
source: string
|
||||
/** SKILL.md 绝对路径(后端注入时读全文) */
|
||||
path: string
|
||||
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
|
||||
duplicates?: string[]
|
||||
}
|
||||
|
||||
/** switch_conversation 命令参数 */
|
||||
export interface SwitchConversationArgs {
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop 命令参数(对齐 ai_chat_stop) */
|
||||
export interface StopArgs {
|
||||
conversation_id?: string | null
|
||||
}
|
||||
|
||||
/** approve 命令参数(对齐 src-tauri ai_approve,Phase3 §2.2 路由表) */
|
||||
export interface ApproveArgs {
|
||||
/** 待审批的工具调用 id(对应 AiApprovalRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 是否批准(true=批准执行,false=拒绝) */
|
||||
approved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
|
||||
*
|
||||
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
|
||||
* - 'once':本次授权(下次同路径仍需问)
|
||||
* - 'always':永久授权(写入会话级信任目录)
|
||||
* - 'deny':拒绝(本次不授权)
|
||||
*/
|
||||
export interface AuthorizeDirArgs {
|
||||
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
|
||||
tool_call_id: string
|
||||
/** 授权决策('once' / 'always' / 'deny') */
|
||||
decision: 'once' | 'always' | 'deny'
|
||||
}
|
||||
|
||||
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
|
||||
export interface ContinueLoopArgs {
|
||||
/** 需继续运行的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
|
||||
/** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */
|
||||
export interface StopLoopArgs {
|
||||
/** 霷停止循环的会话 id */
|
||||
conversation_id: string
|
||||
}
|
||||
33
apps/df-miniapp/src/uni.scss
Normal file
33
apps/df-miniapp/src/uni.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 全局样式变量(uni-app 规范:uni.scss 由 uni-app 自动引入到各页面/组件)
|
||||
*
|
||||
* 颜色基调对齐桌面端暗色主题(便于跨端视觉一致)。
|
||||
*/
|
||||
|
||||
/* 主色调 */
|
||||
$uni-color-primary: #4a9eff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 中性色(暗色背景适配) */
|
||||
$uni-bg-color: #0f0f0f;
|
||||
$uni-bg-color-dark: #1a1a1a;
|
||||
$uni-bg-color-light: #2a2a2a;
|
||||
$uni-text-color: #e0e0e0;
|
||||
$uni-text-color-grey: #999999;
|
||||
$uni-text-color-disable: #666666;
|
||||
$uni-border-color: #333333;
|
||||
|
||||
/* 字号 */
|
||||
$uni-font-size-sm: 24rpx;
|
||||
$uni-font-size-base: 28rpx;
|
||||
$uni-font-size-lg: 32rpx;
|
||||
|
||||
/* 气泡角色配色 */
|
||||
$uni-bubble-user-bg: #4a9eff;
|
||||
$uni-bubble-user-text: #ffffff;
|
||||
$uni-bubble-assistant-bg: #2a2a2a;
|
||||
$uni-bubble-assistant-text: #e0e0e0;
|
||||
$uni-bubble-error-bg: #3a1a1a;
|
||||
$uni-bubble-error-text: #ff6b6b;
|
||||
62
apps/df-miniapp/src/utils/mdRenderer.ts
Normal file
62
apps/df-miniapp/src/utils/mdRenderer.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
|
||||
*
|
||||
* 为什么后处理而非 marked 自定义 renderer:
|
||||
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
|
||||
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
|
||||
* 版本无关,build 不依赖 renderer TS 签名。
|
||||
*
|
||||
* mp-weixin rich-text 限制(已核官方文档,非臆测):
|
||||
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
|
||||
* 跳转需第三方 mp-html(本次不做,记后续)。
|
||||
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
|
||||
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
|
||||
*/
|
||||
|
||||
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
|
||||
function styleTag(html: string, tag: string, style: string): string {
|
||||
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
|
||||
return html.replace(re, (m, attrs: string) => {
|
||||
if (attrs && /\sstyle\s*=/.test(attrs)) {
|
||||
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
|
||||
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
|
||||
const merged = prev.endsWith(';') ? prev : `${prev};`
|
||||
return `style="${merged}${style}"`
|
||||
})
|
||||
}
|
||||
return `<${tag}${attrs || ''} style="${style}">`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 marked.parse 产出的 HTML 注入 inline style。
|
||||
*
|
||||
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
|
||||
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
|
||||
*/
|
||||
export function styleMarkdown(html: string): string {
|
||||
let out = html
|
||||
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
|
||||
out = styleTag(
|
||||
out,
|
||||
'pre',
|
||||
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
|
||||
)
|
||||
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
|
||||
out = styleTag(
|
||||
out,
|
||||
'code',
|
||||
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
|
||||
)
|
||||
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
|
||||
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
|
||||
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
|
||||
out = styleTag(
|
||||
out,
|
||||
'table',
|
||||
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
|
||||
)
|
||||
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
|
||||
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
|
||||
return out
|
||||
}
|
||||
25
apps/df-miniapp/tsconfig.json
Normal file
25
apps/df-miniapp/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"types": ["@dcloudio/types", "@types/node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["node_modules", "dist", "unpackage"]
|
||||
}
|
||||
9
apps/df-miniapp/vite.config.ts
Normal file
9
apps/df-miniapp/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app Vue3 vite 配置:vite-plugin-uni 注入跨端编译(mp-weixin / h5)。
|
||||
// Phase2 落地遗漏本文件致 `uni build -p mp-weixin` 走纯 vite h5(只产 index.html),
|
||||
// 补回后 -p mp-weixin 正确产出小程序文件(app.js/app.json/pages/*)。
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
})
|
||||
@@ -286,6 +286,73 @@ impl ToolDomain {
|
||||
|
||||
// ---- 识别器 -----------------------------------------------------------------
|
||||
|
||||
/// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。
|
||||
///
|
||||
/// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve
|
||||
/// 注入 system prompt;content 里残留的前缀只污染关键词识别——实测会话
|
||||
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`,
|
||||
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见,agent 被迫反复 `list_tasks`。
|
||||
///
|
||||
/// 剥离规则:扫描 `[`,若紧跟已知 mention kind(项目/任务/想法/技能,
|
||||
/// project/task/idea/skill,大小写不敏感)+ 冒号(全/半角 `:`/`:`),则连同到
|
||||
/// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。
|
||||
///
|
||||
/// kind 表与 `MentionRef`(project/task/idea/skill)对齐——单一真相源,新增 mention
|
||||
/// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。
|
||||
fn strip_mention_tags(message: &str) -> String {
|
||||
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
|
||||
let chars: Vec<char> = message.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = String::with_capacity(message.len());
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if chars[i] == '[' {
|
||||
if let Some(close) = try_match_mention(&chars, i, KINDS) {
|
||||
i = close + 1; // 跳过整段 mention(含 `]`)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。
|
||||
///
|
||||
/// 返回匹配 `]` 的索引(`open` 指向 `[`)。kind 大小写不敏感(ASCII)+ 中文直比;
|
||||
/// 冒号接受全角 `:`/半角 `:`;kind 后允许空白;未闭合 `]` 返 `None`(不当 mention,保留原样)。
|
||||
fn try_match_mention(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
let kc: Vec<char> = k.chars().collect();
|
||||
if chars.len() < after + kc.len() {
|
||||
continue;
|
||||
}
|
||||
let slice = &chars[after..after + kc.len()];
|
||||
if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
|
||||
continue;
|
||||
}
|
||||
let mut j = after + kc.len();
|
||||
while j < chars.len() && chars[j].is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') {
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while j < chars.len() {
|
||||
if chars[j] == ']' {
|
||||
return Some(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// 未闭合 `]` → 不当 mention,保留原样
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntentRecognizer;
|
||||
@@ -299,14 +366,16 @@ impl IntentRecognizer {
|
||||
///
|
||||
/// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。
|
||||
pub fn recognize(message: &str) -> (Intent, f32) {
|
||||
// L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project)。
|
||||
let cleaned = strip_mention_tags(message);
|
||||
// 依次按优先级组求胜者。高分组有命中即提前返回。
|
||||
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, ENTITY_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
if let Some(hit) = best_in_group(message, GENERIC_GROUP) {
|
||||
if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
|
||||
return hit;
|
||||
}
|
||||
(Intent::Unknown, 0.0)
|
||||
@@ -347,7 +416,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||
@@ -357,9 +426,12 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||
Intent::File => &[ToolDomain::File],
|
||||
Intent::Project => &[ToolDomain::Data],
|
||||
Intent::Task => &[ToolDomain::Data],
|
||||
Intent::Idea => &[ToolDomain::Data],
|
||||
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
|
||||
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
|
||||
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
|
||||
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
|
||||
Intent::Conversation => &[],
|
||||
Intent::Search => &[ToolDomain::File],
|
||||
Intent::Http => &[ToolDomain::Http],
|
||||
@@ -608,6 +680,47 @@ mod tests {
|
||||
assert_eq!(i, Intent::Search);
|
||||
}
|
||||
|
||||
// --- strip_mention_tags(L0 去噪)---
|
||||
|
||||
#[test]
|
||||
fn strip_removes_all_mention_kinds() {
|
||||
assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
|
||||
assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
|
||||
assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
|
||||
// 英文 kind + 大小写不敏感
|
||||
assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
|
||||
assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
|
||||
// 全角冒号
|
||||
assert_eq!(strip_mention_tags("[项目:DevFlow] 阅读"), " 阅读");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_preserves_non_mention_brackets() {
|
||||
// 非 mention 的 [...] 原样保留(代码片段/错误码)
|
||||
assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
|
||||
assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
|
||||
assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
|
||||
// 未闭合的 mention-like 不剥离(防误吞)
|
||||
assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_mention_prefix_not_misread_as_project() {
|
||||
// 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
|
||||
// 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
|
||||
let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
|
||||
assert_eq!(i, Intent::Code);
|
||||
assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_real_project_word_still_hits_project() {
|
||||
// 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
|
||||
// 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
|
||||
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
|
||||
assert_eq!(i, Intent::Project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_http_zh() {
|
||||
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
|
||||
@@ -713,11 +826,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_project_is_data() {
|
||||
fn subset_project_is_data_and_file() {
|
||||
// L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
|
||||
// Data(create_project/bind_directory)+ File 只读(read_file)共存。
|
||||
let s = tool_subset_for(&Intent::Project);
|
||||
assert!(s.contains(&"create_project"));
|
||||
assert!(s.contains(&"bind_directory"));
|
||||
assert!(!s.contains(&"read_file"));
|
||||
assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,13 +26,15 @@
|
||||
|
||||
// ---- feature flag 占位 ------------------------------------------------------
|
||||
|
||||
/// 规划能力总开关(Phase 1 接入用占位)。
|
||||
/// 规划能力总开关(主 loop 规划开关,门控 filter_tool_defs_planned 接入)。
|
||||
///
|
||||
/// **当前恒 `false`** = 单链 ReAct 旧行为(主 loop 未走 Plan 规划)。
|
||||
/// Phase 1(plan_hint 接入主 loop)落地时翻为 `true` + 条件编译门控。
|
||||
/// **当前 `true`** = 主 loop 走 Plan 规划(agentic/mod.rs:633 调 filter_tool_defs_planned)。
|
||||
/// Phase 1(plan_hint 接入主 loop)已落地,翻 true 启用。
|
||||
/// 注:`PLAN_HINT_ENABLED`(plan_hint 函数 flag,Phase0a 就绪 true)单一定义在 `plan_hint.rs`,
|
||||
/// 本模块仅 `PLANNING_ENABLED`(主 loop 规划开关),消除此前双定义矛盾。
|
||||
pub const PLANNING_ENABLED: bool = false;
|
||||
/// 双层开关:即使 PLANNING_ENABLED=true,若 plan_hint 内部 PLAN_HINT_ENABLED 关闭,
|
||||
/// filter_tool_defs_planned 仍退扁平(任一关闭退旧行为,可回退)。
|
||||
pub const PLANNING_ENABLED: bool = true;
|
||||
|
||||
/// Plan 层级硬上限(防爆深,防规划 LLM 退化成超长链)。
|
||||
///
|
||||
@@ -632,10 +634,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_flags_default_off() {
|
||||
// PLANNING_ENABLED 当前关(主 loop 未走规划),Phase 1 接入时翻 true。
|
||||
fn feature_flags_default_on() {
|
||||
// PLANNING_ENABLED 当前开(主 loop 走 filter_tool_defs_planned 规划),Phase 1 已落地。
|
||||
// PLAN_HINT_ENABLED(plan_hint 函数 flag)在 plan_hint.rs 单一定义,此处不再断言。
|
||||
assert!(!PLANNING_ENABLED);
|
||||
assert!(PLANNING_ENABLED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,18 +22,49 @@ pub struct ShellResult {
|
||||
pub enum ShellType {
|
||||
/// Windows cmd.exe (默认 Windows)
|
||||
Cmd,
|
||||
/// PowerShell
|
||||
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
|
||||
PowerShell,
|
||||
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
|
||||
Pwsh,
|
||||
/// Unix sh (默认非 Windows)
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) { ShellType::Cmd } else { ShellType::Sh }
|
||||
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
if cfg!(windows) {
|
||||
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||
} else {
|
||||
ShellType::Sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||
///
|
||||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||
fn probe_pwsh() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
let mut cmd = std::process::Command::new("pwsh");
|
||||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shell 命令执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellRequest {
|
||||
@@ -65,6 +96,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Pwsh => {
|
||||
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
|
||||
let mut c = tokio::process::Command::new("pwsh");
|
||||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
c
|
||||
}
|
||||
ShellType::Cmd => {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(&request.command);
|
||||
|
||||
@@ -1,741 +0,0 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
//!
|
||||
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
|
||||
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[path = "scan_helpers.rs"]
|
||||
mod scan_helpers;
|
||||
pub use scan_helpers::ImageRef;
|
||||
use scan_helpers::{collect_images, strip_readme_noise};
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
|
||||
// ============================================================
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
|
||||
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
|
||||
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)
|
||||
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 读 README 原始内容(不做处理)
|
||||
fn read_readme_raw(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\nA test project.\nMore.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
// ── normalize_path 跨平台归一化 ──
|
||||
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
|
||||
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
|
||||
// 不断言精确串,避免与平台耦合。
|
||||
#[test]
|
||||
fn normalize_path_uses_forward_slash_and_lowercase() {
|
||||
// 不存在的路径走降级分支(trim + replace + lowercase)
|
||||
let n = normalize_path(r"C:\Foo\Bar\");
|
||||
assert!(
|
||||
!n.contains('\\'),
|
||||
"反斜杠未归一: {n}"
|
||||
);
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_equivalent_inputs_equal() {
|
||||
// 两种写法应归一为同一串(去重场景)
|
||||
let a = normalize_path(r"C:\Foo\Bar");
|
||||
let b = normalize_path("C:/Foo/Bar/");
|
||||
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_relative_is_lowercase_and_forward_slash() {
|
||||
let n = normalize_path(r"..\Some\Path");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
|
||||
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
|
||||
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_null_not_misclassified() {
|
||||
let d = scratch("ws-null");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"x","workspaces":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!is_monorepo(&d),
|
||||
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
|
||||
);
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_object_treated_as_real() {
|
||||
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
|
||||
let d = scratch("ws-obj");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_non_dir_returns_false() {
|
||||
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
|
||||
}
|
||||
}
|
||||
276
crates/df-project/src/scan/discover.rs
Normal file
276
crates/df-project/src/scan/discover.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::stack::{detect_stack, has_file_with_ext};
|
||||
use super::SAMPLE_IGNORED_DIRS;
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
|
||||
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
|
||||
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
|
||||
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_null_not_misclassified() {
|
||||
let d = scratch("ws-null");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"x","workspaces":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!is_monorepo(&d),
|
||||
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
|
||||
);
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_workspaces_object_treated_as_real() {
|
||||
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
|
||||
let d = scratch("ws-obj");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_monorepo_non_dir_returns_false() {
|
||||
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
|
||||
}
|
||||
}
|
||||
114
crates/df-project/src/scan/mod.rs
Normal file
114
crates/df-project/src/scan/mod.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
//!
|
||||
//! ## 模块拆分(2026-06-22,REFACTOR-260619-10)
|
||||
//!
|
||||
//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化:
|
||||
//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析)
|
||||
//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`)
|
||||
//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`)
|
||||
//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录)
|
||||
//!
|
||||
//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`),
|
||||
//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。
|
||||
//!
|
||||
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
|
||||
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[path = "../scan_helpers.rs"]
|
||||
mod scan_helpers;
|
||||
pub use scan_helpers::ImageRef;
|
||||
|
||||
pub mod discover;
|
||||
pub mod readme;
|
||||
pub mod sample;
|
||||
pub mod stack;
|
||||
|
||||
// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)──
|
||||
pub use discover::{discover_projects, is_monorepo, DiscoveredProject};
|
||||
pub use readme::extract_description;
|
||||
pub use sample::{collect_sample, ProjectSample};
|
||||
pub use stack::detect_stack;
|
||||
|
||||
// ============================================================
|
||||
// 跨模块共享常量/helper
|
||||
// ============================================================
|
||||
|
||||
/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。
|
||||
///
|
||||
/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。
|
||||
pub(super) const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)。
|
||||
///
|
||||
/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。
|
||||
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── normalize_path 跨平台归一化 ──
|
||||
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
|
||||
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
|
||||
// 不断言精确串,避免与平台耦合。
|
||||
#[test]
|
||||
fn normalize_path_uses_forward_slash_and_lowercase() {
|
||||
// 不存在的路径走降级分支(trim + replace + lowercase)
|
||||
let n = normalize_path(r"C:\Foo\Bar\");
|
||||
assert!(
|
||||
!n.contains('\\'),
|
||||
"反斜杠未归一: {n}"
|
||||
);
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_equivalent_inputs_equal() {
|
||||
// 两种写法应归一为同一串(去重场景)
|
||||
let a = normalize_path(r"C:\Foo\Bar");
|
||||
let b = normalize_path("C:/Foo/Bar/");
|
||||
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_relative_is_lowercase_and_forward_slash() {
|
||||
let n = normalize_path(r"..\Some\Path");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
}
|
||||
122
crates/df-project/src/scan/readme.rs
Normal file
122
crates/df-project/src/scan/readme.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! README 读取与首段抽取 — 纯 IO + 文本规则。
|
||||
//!
|
||||
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
|
||||
//! (`collect_sample`)共用,故 `pub(super)` 暴露。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::truncate_chars;
|
||||
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 读 README 原始内容(不做处理)。
|
||||
///
|
||||
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
|
||||
/// 返回首个存在的文件内容。
|
||||
pub(super) fn read_readme_raw(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
|
||||
///
|
||||
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
if started {
|
||||
break; // 段落结束
|
||||
}
|
||||
continue; // 首段尚未开始,跳过开头空行
|
||||
}
|
||||
// 段落开始后不再跳行,直接累加
|
||||
if !started {
|
||||
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
|
||||
if line.starts_with('#')
|
||||
|| line.starts_with("<!--")
|
||||
|| line.starts_with('!')
|
||||
|| line.starts_with('<')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let desc = text.trim().to_string();
|
||||
if desc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
// 标题 + badge 图片行应跳过,首段为正文
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# My Project\n\n\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
|
||||
)
|
||||
.unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
assert!(desc.contains("示例项目"));
|
||||
assert!(desc.contains("第二行正文"));
|
||||
assert!(!desc.contains("My Project"));
|
||||
assert!(!desc.contains("badge"));
|
||||
assert!(!desc.contains("安装"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_no_readme_returns_none() {
|
||||
let d = scratch("nodesc");
|
||||
assert!(extract_description(&d).is_none());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_truncates_long() {
|
||||
let d = scratch("longdesc");
|
||||
let long = "啊".repeat(500);
|
||||
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
|
||||
let desc = extract_description(&d).unwrap();
|
||||
// 截断到 200 字 + 末尾「…(已截断)」标记
|
||||
assert!(desc.chars().count() <= 210);
|
||||
assert!(desc.contains("已截断"));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
167
crates/df-project/src/scan/sample.rs
Normal file
167
crates/df-project/src/scan/sample.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
//! 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码。
|
||||
//!
|
||||
//! README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::readme::read_readme_raw;
|
||||
use super::scan_helpers::{collect_images, strip_readme_noise, ImageRef};
|
||||
use super::{truncate_chars, SAMPLE_IGNORED_DIRS};
|
||||
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\nA test project.\nMore.",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
176
crates/df-project/src/scan/stack.rs
Normal file
176
crates/df-project/src/scan/stack.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! 技术栈探测 — 浅读根目录标志文件识别技术栈。
|
||||
//!
|
||||
//! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志,
|
||||
//! 不递归遍历(控制性能与安全)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)。
|
||||
///
|
||||
/// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用,
|
||||
/// 故 `pub(super)`。
|
||||
pub(super) fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
21
crates/df-relay/Cargo.toml
Normal file
21
crates/df-relay/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "df-relay"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
# binary 入口(main.rs)日志初始化
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# HTTP + WS 服务端(axum ws 内部用 tungstenite 协议层;df-relay 不直接依赖 tokio-tungstenite。
|
||||
# TLS 由 nginx wss 终止,:9180 明文 ws → 去 native-tls 避 openssl 依赖,利 linux 交叉/容器编译)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
21
crates/df-relay/Dockerfile
Normal file
21
crates/df-relay/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# df-relay 跨端中继(Phase3 云端部署,u-work.1216.top)
|
||||
# multi-stage:rust:slim 编译 df-relay → debian:slim 运行(static-ish binary)
|
||||
#
|
||||
# 构建(context = repo 根,workspace):
|
||||
# docker build -f crates/df-relay/Dockerfile -t df-relay .
|
||||
# 运行(容器内 :8080,映射宿主 :9180,避开测试机 8080 占用):
|
||||
# docker run -d --name df-relay --restart unless-stopped -p 9180:8080 df-relay
|
||||
|
||||
FROM rust:1.85-slim AS builder
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN cargo build --release -p df-relay
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /build/target/release/df-relay /usr/local/bin/df-relay
|
||||
ENV DF_RELAY_ADDR=0.0.0.0:8080
|
||||
EXPOSE 8080
|
||||
CMD ["df-relay"]
|
||||
145
crates/df-relay/src/broadcast.rs
Normal file
145
crates/df-relay/src/broadcast.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! df-relay 广播消息骨架
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。
|
||||
//! 中继消息骨架定义「谁发给谁」的路由语义:
|
||||
//! - `Event`:桌面端事件 → 广播给该 device 绑定的小程序
|
||||
//! - `Command`:小程序指令 → 转发给该 device 绑定的桌面端
|
||||
//! - `Control`:控制面消息(配对/心跳/在线状态)
|
||||
//!
|
||||
//! 与 df-tunnel::TunnelMessage 区分:
|
||||
//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元
|
||||
//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围)
|
||||
//!
|
||||
//! AiChatEvent JSON 透传:relay 不解析 AiChatEvent(类型在 src-tauri/df-types,
|
||||
//! relay 不依赖避跨 crate 强耦合),payload 用 serde_json::Value 透传。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::conn::ConnId;
|
||||
|
||||
/// 客户端类型(用于路由判断)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientKind {
|
||||
/// 桌面端(出站长连接,事件真相源)
|
||||
Device,
|
||||
/// 小程序(远程操作发起方)
|
||||
Miniapp,
|
||||
}
|
||||
|
||||
/// 中继路由消息语义分类
|
||||
///
|
||||
/// - `Event`:桌面端 → 小程序(渲染事件,AiChatEvent 透传)
|
||||
/// - `Command`:小程序 → 桌面端(操作指令,触发 Tauri command)
|
||||
/// - `Control`:控制面(配对/心跳/Ping-Pong,不进业务流)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageKind {
|
||||
Event,
|
||||
Command,
|
||||
Control,
|
||||
}
|
||||
|
||||
/// 中继路由消息骨架(云后端内部的路由单元)
|
||||
///
|
||||
/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id
|
||||
/// 找到绑定的对端连接进行转发/广播。payload 为原始 JSON,转发时不解析业务字段,
|
||||
/// 保持轻量。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BroadcastMessage {
|
||||
/// 配对绑定的设备 ID(桌面端首次配置时生成)
|
||||
pub device_id: String,
|
||||
/// 消息语义(Event / Command / Control)
|
||||
pub kind: MessageKind,
|
||||
/// 消息来源连接(用于鉴权校验:device 不能发 Command)
|
||||
pub source: ConnId,
|
||||
/// 来源客户端类型
|
||||
pub from: ClientKind,
|
||||
/// 消息载荷(原始 JSON,透传不解析)
|
||||
pub payload: serde_json::Value,
|
||||
/// 时间戳(毫秒,用于冲突判断 —— 桌面端为真相源,但保留时间戳辅助)
|
||||
pub ts: i64,
|
||||
}
|
||||
|
||||
impl BroadcastMessage {
|
||||
/// 便捷构造:device 发出的事件
|
||||
pub fn from_device(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Event,
|
||||
source,
|
||||
from: ClientKind::Device,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷构造:小程序发出的指令
|
||||
pub fn from_miniapp(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Command,
|
||||
source,
|
||||
from: ClientKind::Miniapp,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷构造:控制面消息
|
||||
pub fn control(
|
||||
device_id: impl Into<String>,
|
||||
source: ConnId,
|
||||
from: ClientKind,
|
||||
payload: serde_json::Value,
|
||||
ts: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_id: device_id.into(),
|
||||
kind: MessageKind::Control,
|
||||
source,
|
||||
from,
|
||||
payload,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端在线状态(用于离线降级提示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PresenceState {
|
||||
/// 在线
|
||||
Online,
|
||||
/// 离线(对端可缓存指令待重连)
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// 控制面消息(配对绑定 / 心跳 / 在线状态广播)
|
||||
///
|
||||
/// 仅作为 payload 的语义约定,relay 对 Control 消息同样透传;部分控制语义
|
||||
/// (如 Ping/Pong 心跳)由 WS 连接层直接处理,不进 BroadcastMessage 流。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "control_kind", rename_all = "snake_case")]
|
||||
pub enum ControlMessage {
|
||||
/// 配对绑定请求(小程序持 token 申请绑定 device_id)
|
||||
Pair { device_id: String, token: String },
|
||||
/// 心跳(保活 + 在线确认)
|
||||
Heartbeat { device_id: String },
|
||||
/// 在线状态变更通知
|
||||
Presence { device_id: String, state: PresenceState },
|
||||
/// 心跳 Ping(WS 应用层心跳,与协议层 Ping 区分)
|
||||
Ping,
|
||||
/// 心跳 Pong
|
||||
Pong,
|
||||
}
|
||||
202
crates/df-relay/src/conn.rs
Normal file
202
crates/df-relay/src/conn.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! df-relay 连接注册表
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— 按 device_id 配对路由(非全局广播)。
|
||||
//!
|
||||
//! 注册表结构:`HashMap<conn_id, ConnHandle>` + `device_id → conn_id` 索引。
|
||||
//! 配对语义:小程序 device_id ↔ 桌面端绑定该 device_id 的 token(同 device_id
|
||||
//! 的小程序与桌面端互为对端)。relay 按 device_id 找到对端连接转发消息。
|
||||
//!
|
||||
//! 并发:tokio::sync::Mutex 单锁(注册表操作高频但临界区小,先简单后优化)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::broadcast::{BroadcastMessage, ClientKind};
|
||||
|
||||
/// 连接唯一标识(u64 自增,进程内唯一)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConnId(pub u64);
|
||||
|
||||
impl ConnId {
|
||||
/// 伪值(用于无来源场景,如服务端构造的控制消息)
|
||||
pub const NIL: ConnId = ConnId(0);
|
||||
}
|
||||
|
||||
static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// 分配下一个 conn_id(进程内递增,从 1 起,0 留作 NIL)
|
||||
pub fn next_conn_id() -> ConnId {
|
||||
ConnId(NEXT_CONN_ID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// 连接句柄(注册表条目)
|
||||
///
|
||||
/// 每个连接持一个 mpsc::UnboundedSender,广播时向其投递 BroadcastMessage,
|
||||
/// 由连接自己的读取任务转发给对端 socket。
|
||||
#[derive(Debug)]
|
||||
pub struct ConnHandle {
|
||||
/// 连接 ID
|
||||
pub id: ConnId,
|
||||
/// 客户端类型
|
||||
pub kind: ClientKind,
|
||||
/// 配对绑定的设备 ID(桌面端首次配置生成,小程序携带)
|
||||
pub device_id: String,
|
||||
/// 广播投递通道(连接读取任务消费 → 写入 socket)
|
||||
pub sender: mpsc::UnboundedSender<BroadcastMessage>,
|
||||
}
|
||||
|
||||
impl ConnHandle {
|
||||
pub fn new(
|
||||
id: ConnId,
|
||||
kind: ClientKind,
|
||||
device_id: String,
|
||||
sender: mpsc::UnboundedSender<BroadcastMessage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
device_id,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接注册表(RelayState 内部状态)
|
||||
#[derive(Default)]
|
||||
pub struct ConnRegistry {
|
||||
/// conn_id → 连接句柄
|
||||
by_id: HashMap<ConnId, ConnHandle>,
|
||||
/// device_id → 绑定的连接集合(同 device_id 的小程序 + 桌面端)
|
||||
/// 设计为 Vec:兼容同 device_id 多连接(多端登录),MVP 一般 1+1。
|
||||
by_device: HashMap<String, Vec<ConnId>>,
|
||||
}
|
||||
|
||||
impl ConnRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 注册连接
|
||||
pub fn add(&mut self, handle: ConnHandle) {
|
||||
let id = handle.id;
|
||||
let device_id = handle.device_id.clone();
|
||||
self.by_device.entry(device_id).or_default().push(id);
|
||||
self.by_id.insert(id, handle);
|
||||
}
|
||||
|
||||
/// 注销连接(返回其 device_id 用于清理索引)
|
||||
pub fn remove(&mut self, id: ConnId) -> Option<String> {
|
||||
let handle = self.by_id.remove(&id)?;
|
||||
let device_id = handle.device_id.clone();
|
||||
if let Some(ids) = self.by_device.get_mut(&device_id) {
|
||||
ids.retain(|c| *c != id);
|
||||
if ids.is_empty() {
|
||||
self.by_device.remove(&device_id);
|
||||
}
|
||||
}
|
||||
Some(device_id)
|
||||
}
|
||||
|
||||
/// 查询 device_id 是否有在线连接(离线降级判断用)
|
||||
pub fn is_device_online(&self, device_id: &str) -> bool {
|
||||
self.by_device
|
||||
.get(device_id)
|
||||
.map(|ids| !ids.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 投递消息给指定 device_id 配对的对端连接(排除来源 source)
|
||||
///
|
||||
/// 路由规则:
|
||||
/// - Event(Device 发):转发给同 device_id 的所有 Miniapp 连接
|
||||
/// - Command(Miniapp 发):转发给同 device_id 的所有 Device 连接
|
||||
/// - Control:转发给同 device_id 的所有连接(除来源)
|
||||
///
|
||||
/// 返回成功投递的连接数(用于日志/降级判断)。
|
||||
pub fn route(&self, msg: &BroadcastMessage) -> usize {
|
||||
let Some(ids) = self.by_device.get(&msg.device_id) else {
|
||||
return 0;
|
||||
};
|
||||
let mut delivered = 0usize;
|
||||
for &id in ids {
|
||||
if id == msg.source {
|
||||
// 不回环给来源
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = self.by_id.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
match (msg.kind, msg.from, handle.kind) {
|
||||
// Event:仅投递给 Miniapp
|
||||
(crate::broadcast::MessageKind::Event, _, ClientKind::Miniapp) => {}
|
||||
// Command:仅投递给 Device
|
||||
(crate::broadcast::MessageKind::Command, _, ClientKind::Device) => {}
|
||||
// Control:全投递(除来源)
|
||||
(crate::broadcast::MessageKind::Control, _, _) => {}
|
||||
// 其余方向不匹配(Event 不投递给 Device,Command 不投递给 Miniapp)
|
||||
_ => continue,
|
||||
}
|
||||
if handle.sender.send(msg.clone()).is_ok() {
|
||||
delivered += 1;
|
||||
}
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
/// 当前总连接数(诊断用)
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_id.len()
|
||||
}
|
||||
|
||||
/// 是否为空(诊断用)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 共享中继状态(axum State 传递)
|
||||
///
|
||||
/// 注册表用 tokio Mutex 包裹:广播路径在锁内调用 route,锁粒度小。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RelayState {
|
||||
inner: Arc<Mutex<ConnRegistry>>,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 从 Arc 直接构造(用于 axum state)
|
||||
pub fn from_arc(inner: Arc<Mutex<ConnRegistry>>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// 暴露内部 Arc(测试/外部 start 使用)
|
||||
pub fn inner(&self) -> Arc<Mutex<ConnRegistry>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
|
||||
/// 注册连接
|
||||
pub async fn add_conn(&self, handle: ConnHandle) {
|
||||
self.inner.lock().await.add(handle);
|
||||
}
|
||||
|
||||
/// 注销连接
|
||||
pub async fn remove_conn(&self, id: ConnId) -> Option<String> {
|
||||
self.inner.lock().await.remove(id)
|
||||
}
|
||||
|
||||
/// 查询 device 在线状态
|
||||
pub async fn is_device_online(&self, device_id: &str) -> bool {
|
||||
self.inner.lock().await.is_device_online(device_id)
|
||||
}
|
||||
|
||||
/// 投递消息给对端连接(返回成功投递数)
|
||||
pub async fn route(&self, msg: &BroadcastMessage) -> usize {
|
||||
self.inner.lock().await.route(msg)
|
||||
}
|
||||
}
|
||||
41
crates/df-relay/src/error.rs
Normal file
41
crates/df-relay/src/error.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! df-relay 错误类型
|
||||
//!
|
||||
//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败,
|
||||
//! thiserror 派生保留错误链。脚手架阶段仅定义类型。
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// 中继服务运行期错误
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RelayError {
|
||||
/// 服务启动失败(端口占用 / 绑定失败)
|
||||
#[error("中继服务启动失败: {0}")]
|
||||
Start(String),
|
||||
|
||||
/// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝)
|
||||
#[error("客户端管理错误: {0}")]
|
||||
Client(String),
|
||||
|
||||
/// 广播失败(无可用连接 / 发送失败)
|
||||
#[error("广播失败: {0}")]
|
||||
Broadcast(String),
|
||||
|
||||
/// WS 协议层错误(底层 axum::extract::ws / tungstenite)
|
||||
#[error("WS 协议错误: {0}")]
|
||||
WebSocket(String),
|
||||
|
||||
/// 序列化/反序列化失败
|
||||
#[error("消息序列化失败: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// 广播发送失败(mpsc channel 对端已断开)
|
||||
#[error("广播 channel 发送失败: {0}")]
|
||||
Channel(String),
|
||||
|
||||
/// 其他未归类错误(降级通道)
|
||||
#[error("中继内部错误: {0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// 模块级 Result 别名
|
||||
pub type Result<T> = std::result::Result<T, RelayError>;
|
||||
29
crates/df-relay/src/lib.rs
Normal file
29
crates/df-relay/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! # df-relay
|
||||
//!
|
||||
//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。
|
||||
//!
|
||||
//! ## 职责
|
||||
//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)
|
||||
//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序
|
||||
//! - 纯转发,无业务逻辑(保持轻量,设计文档明确)
|
||||
//!
|
||||
//! ## 边界
|
||||
//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合)
|
||||
//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发)
|
||||
//!
|
||||
//! ## 模块
|
||||
//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage / MessageKind)
|
||||
//! - [`conn`]:连接注册表(ConnId / ConnHandle / ConnRegistry / RelayState)
|
||||
//! - [`relay`]:RelayServer trait + DefaultRelayServer + axum WS 路由与连接生命周期
|
||||
//! - [`error`]:RelayError 错误类型
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod conn;
|
||||
pub mod error;
|
||||
pub mod relay;
|
||||
|
||||
// 顶层再导出常用项
|
||||
pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, MessageKind, PresenceState};
|
||||
pub use conn::{next_conn_id, ConnHandle, ConnId, ConnRegistry, RelayState};
|
||||
pub use error::{RelayError, Result};
|
||||
pub use relay::{build_router, DefaultRelayServer, RelayServer};
|
||||
21
crates/df-relay/src/main.rs
Normal file
21
crates/df-relay/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! df-relay binary 入口 — 启动跨端 AI Chat 云中继 axum WS Server。
|
||||
//!
|
||||
//! 监听 `0.0.0.0:8080`(`DF_RELAY_ADDR` env 覆盖),提供两个 WS 端点:
|
||||
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
|
||||
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
|
||||
//!
|
||||
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
|
||||
//!
|
||||
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
|
||||
|
||||
use df_relay::{DefaultRelayServer, RelayServer};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> df_relay::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
|
||||
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
|
||||
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
|
||||
let server = DefaultRelayServer::new();
|
||||
server.start(&addr).await
|
||||
}
|
||||
415
crates/df-relay/src/relay.rs
Normal file
415
crates/df-relay/src/relay.rs
Normal file
@@ -0,0 +1,415 @@
|
||||
//! df-relay 中继服务核心实现
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
|
||||
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对),按 device_id
|
||||
//! 配对转发(非全局广播),纯转发无业务逻辑。
|
||||
//!
|
||||
//! 协议(简单握手):
|
||||
//! 1. 客户端建立 WS 后,首条消息发 JSON `Hello { kind, device_id, token }` 宣告身份。
|
||||
//! 2. relay 校验 token(MVP:env `DF_RELAY_TOKEN` 或硬编码常量;生产级鉴权留 Phase3)。
|
||||
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
||||
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
|
||||
//!
|
||||
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
||||
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||
use crate::error::{RelayError, Result};
|
||||
|
||||
/// MVP 默认 token(env `DF_RELAY_TOKEN` 缺省时回退)。
|
||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||
const DEFAULT_TOKEN: &str = "devflow-relay-default-token";
|
||||
|
||||
/// 读取期望 token(env 优先,fallback 硬编码)
|
||||
fn expected_token() -> String {
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| DEFAULT_TOKEN.to_string())
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// 客户端类型("device" / "miniapp")
|
||||
pub kind: ClientKindWire,
|
||||
/// 配对绑定的设备 ID
|
||||
pub device_id: String,
|
||||
/// 配对 token
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientKindWire {
|
||||
Device,
|
||||
Miniapp,
|
||||
}
|
||||
|
||||
impl From<ClientKindWire> for ClientKind {
|
||||
fn from(w: ClientKindWire) -> Self {
|
||||
match w {
|
||||
ClientKindWire::Device => ClientKind::Device,
|
||||
ClientKindWire::Miniapp => ClientKind::Miniapp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中继服务抽象
|
||||
///
|
||||
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
|
||||
#[async_trait]
|
||||
pub trait RelayServer: Send + Sync {
|
||||
/// 启动 HTTP/WS 服务监听指定地址
|
||||
async fn start(&self, addr: &str) -> Result<()>;
|
||||
|
||||
/// 广播消息给指定 device_id 绑定的对端
|
||||
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
|
||||
|
||||
/// 查询 device 是否有在线连接(离线降级判断用)
|
||||
fn is_device_online(&self, device_id: &str) -> bool;
|
||||
}
|
||||
|
||||
/// 默认中继服务(持有共享 RelayState)
|
||||
pub struct DefaultRelayServer {
|
||||
state: RelayState,
|
||||
}
|
||||
|
||||
impl DefaultRelayServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: RelayState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从既有 RelayState 构造(测试 / 外部复用)
|
||||
pub fn with_state(state: RelayState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
/// 暴露共享状态(外部可读连接数等)
|
||||
pub fn state(&self) -> RelayState {
|
||||
self.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DefaultRelayServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RelayServer for DefaultRelayServer {
|
||||
async fn start(&self, addr: &str) -> Result<()> {
|
||||
let socket_addr: SocketAddr = addr
|
||||
.parse()
|
||||
.map_err(|e| RelayError::Start(format!("地址解析失败 {addr}: {e}")))?;
|
||||
let app = build_router(self.state.clone());
|
||||
let listener = tokio::net::TcpListener::bind(&socket_addr)
|
||||
.await
|
||||
.map_err(|e| RelayError::Start(format!("监听绑定失败 {addr}: {e}")))?;
|
||||
tracing::info!(%addr, "df-relay WS 服务已启动");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.map_err(|e| RelayError::Start(format!("axum::serve 失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()> {
|
||||
let delivered = self.state.route(&msg).await;
|
||||
if delivered == 0 {
|
||||
// 对端离线不算硬错误(MVP 返回 Ok,离线降级由调用方据 is_device_online 判断)
|
||||
tracing::debug!(
|
||||
device_id = %msg.device_id,
|
||||
kind = ?msg.kind,
|
||||
"广播无对端在线(消息丢弃)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_device_online(&self, device_id: &str) -> bool {
|
||||
// trait 同步签名:tokio Mutex 用 try_lock 快照,失败保守返回 false
|
||||
match self.state.inner().try_lock() {
|
||||
Ok(g) => g.is_device_online(device_id),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// axum WS 路由构造
|
||||
///
|
||||
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
|
||||
/// 共享 RelayState。端点仅决定「期望的客户端类型」,真正的身份宣告在首消息
|
||||
/// Hello 中再次校验(防误连/误用)。
|
||||
pub fn build_router(state: RelayState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws/device", get(device_ws_handler))
|
||||
.route("/ws/miniapp", get(miniapp_ws_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// 桌面端 WS upgrade handler
|
||||
async fn device_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<RelayState>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::debug!("桌面端 WS 连接接入");
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Device))
|
||||
}
|
||||
|
||||
/// 小程序 WS upgrade handler
|
||||
async fn miniapp_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<RelayState>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::debug!("小程序 WS 连接接入");
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, ClientKindWire::Miniapp))
|
||||
}
|
||||
|
||||
/// WS 连接生命周期(握手 → 收发循环 → 注销)
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. 等待首条 Hello 文本帧,校验 kind 与 token。
|
||||
/// 2. 校验通过:分配 conn_id + mpsc,注册 ConnHandle,派发广播读取任务。
|
||||
/// 3. 主循环:从 socket recv 文本帧 → 构造 BroadcastMessage → route 投递。
|
||||
/// 4. 同时读取 mpsc 广播队列 → 写回 socket(双任务用 split sink/stream)。
|
||||
/// 5. 任一端断开 → 注销连接、关闭 mpsc。
|
||||
async fn handle_connection(socket: WebSocket, state: RelayState, expected: ClientKindWire) {
|
||||
// 握手阶段:等待首条 Hello
|
||||
let (mut socket_tx, mut socket_rx) = socket.split();
|
||||
|
||||
let hello = match recv_hello(&mut socket_rx).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "握手失败:未收到合法 Hello");
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"handshake_failed"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 身份 + token 双因子校验
|
||||
if hello.kind != expected {
|
||||
tracing::warn!(
|
||||
?hello.kind,
|
||||
?expected,
|
||||
"握手失败:客户端类型与端点不匹配"
|
||||
);
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"kind_mismatch"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
if hello.token != expected_token() {
|
||||
tracing::warn!(
|
||||
device_id = %hello.device_id,
|
||||
"握手失败:token 校验不通过"
|
||||
);
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"auth_failed"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
|
||||
let conn_id = next_conn_id();
|
||||
let kind: ClientKind = hello.kind.into();
|
||||
let device_id = hello.device_id.clone();
|
||||
tracing::info!(
|
||||
conn_id = conn_id.0,
|
||||
?kind,
|
||||
device_id = %device_id,
|
||||
"连接握手通过,进入收发循环"
|
||||
);
|
||||
|
||||
// 握手通过:立即发 ack 控制帧给客户端。
|
||||
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
|
||||
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
|
||||
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
|
||||
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);
|
||||
state.add_conn(handle).await;
|
||||
|
||||
// 派发广播读取任务:从 bc_rx 取消息 → 序列化 → 写 socket
|
||||
let mut bc_task = tokio::spawn(broadcast_pump(bc_rx, socket_tx));
|
||||
|
||||
// 主循环:从 socket recv → 构造 BroadcastMessage → route
|
||||
loop {
|
||||
tokio::select! {
|
||||
// socket 入帧
|
||||
maybe_msg = socket_rx.next() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
// MVP 仅支持文本帧;二进制帧忽略(协议层可后续扩展)
|
||||
tracing::debug!(conn_id = conn_id.0, "收到二进制帧,忽略");
|
||||
}
|
||||
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {
|
||||
// axum/tungstenite 协议层 Ping/Pong 自动处理,这里仅记录
|
||||
tracing::trace!(conn_id = conn_id.0, "协议层 Ping/Pong");
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
tracing::info!(conn_id = conn_id.0, "客户端主动关闭连接");
|
||||
break;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "socket 接收错误,断开");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 广播 pump 任务结束(socket_tx 关闭或 mpsc 关闭)
|
||||
res = &mut bc_task => {
|
||||
match res {
|
||||
Ok(()) => {
|
||||
tracing::debug!(conn_id = conn_id.0, "广播 pump 任务正常结束");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "广播 pump 任务 panic");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注销连接
|
||||
if let Some(d) = state.remove_conn(conn_id).await {
|
||||
tracing::info!(conn_id = conn_id.0, device_id = %d, "连接已注销");
|
||||
}
|
||||
// 结束 pump 任务(若仍在运行)
|
||||
bc_task.abort();
|
||||
}
|
||||
|
||||
/// 接收并解析首条 Hello 文本帧
|
||||
async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Result<Hello> {
|
||||
let deadline = tokio::time::Duration::from_secs(10);
|
||||
let next = tokio::time::timeout(deadline, rx.next())
|
||||
.await
|
||||
.map_err(|_| RelayError::Client("握手超时(10s 未收到 Hello)".into()))?;
|
||||
let msg = next
|
||||
.ok_or_else(|| RelayError::Client("握手阶段连接关闭".into()))?
|
||||
.map_err(|e| RelayError::WebSocket(format!("握手 recv 失败: {e}")))?;
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Binary(_) => {
|
||||
return Err(RelayError::Client("握手首帧必须为文本".into()));
|
||||
}
|
||||
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
|
||||
};
|
||||
let hello: Hello =
|
||||
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
||||
Ok(hello)
|
||||
}
|
||||
|
||||
/// 处理入站文本帧(构造 BroadcastMessage → route)
|
||||
async fn handle_inbound_text(
|
||||
state: &RelayState,
|
||||
conn_id: ConnId,
|
||||
kind: ClientKind,
|
||||
device_id: &str,
|
||||
raw: &str,
|
||||
) -> Result<()> {
|
||||
// 入站文本即业务 payload(relay 不解析),包成 BroadcastMessage
|
||||
// payload 直接用原始 JSON 值;若客户端发非 JSON 文本,则包成字符串值
|
||||
let payload: serde_json::Value = serde_json::from_str(raw).unwrap_or(serde_json::Value::String(raw.to_string()));
|
||||
|
||||
let now = now_ms();
|
||||
let (msg_kind, from) = match kind {
|
||||
ClientKind::Device => (MessageKind::Event, ClientKind::Device),
|
||||
ClientKind::Miniapp => (MessageKind::Command, ClientKind::Miniapp),
|
||||
};
|
||||
let msg = BroadcastMessage {
|
||||
device_id: device_id.to_string(),
|
||||
kind: msg_kind,
|
||||
source: conn_id,
|
||||
from,
|
||||
payload,
|
||||
ts: now,
|
||||
};
|
||||
let delivered = state.route(&msg).await;
|
||||
tracing::debug!(
|
||||
conn_id = conn_id.0,
|
||||
?msg_kind,
|
||||
device_id = %device_id,
|
||||
delivered,
|
||||
"入站消息已路由"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 广播 pump:从 mpsc 取消息,序列化后写回 socket sink
|
||||
///
|
||||
/// 任务退出条件:bc_rx 关闭(对端 handle 全部 drop)/ socket_tx 关闭出错。
|
||||
async fn broadcast_pump(
|
||||
mut bc_rx: mpsc::UnboundedReceiver<BroadcastMessage>,
|
||||
mut socket_tx: futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
) {
|
||||
while let Some(msg) = bc_rx.recv().await {
|
||||
let text = match serde_json::to_string(&msg) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "广播消息序列化失败,跳过");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = socket_tx.send(Message::Text(text)).await {
|
||||
tracing::warn!(error = %e, "广播写回 socket 失败,pump 退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷发送文本帧
|
||||
async fn send_text(
|
||||
tx: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
text: &str,
|
||||
) -> Result<()> {
|
||||
tx.send(Message::Text(text.to_string()))
|
||||
.await
|
||||
.map_err(|e| RelayError::WebSocket(format!("发送失败: {e}")))
|
||||
}
|
||||
|
||||
/// 当前毫秒时间戳(避开 chrono workspace 依赖,直接用 std + SystemTime)
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -131,6 +131,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
|
||||
// ============================================================
|
||||
|
||||
/// 灵感多条件查询入参。
|
||||
///
|
||||
/// 所有字段可选;全 None → 等价 `list_all`(向后兼容旧全量调用)。
|
||||
/// 设计对齐 `查询能力补全方案-2026-06-21.md` 4.1:可选字段 struct 而非逐个加 IPC 参数,
|
||||
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 分支化参数绑定)。
|
||||
///
|
||||
/// - `status`:状态精确匹配(走 `idx_tasks_status` 同类索引语义,后端 WHERE 收口前端 filter)
|
||||
/// - `keyword`:`title LIKE %kw% OR description LIKE %kw%`(对齐知识库 LIKE 检索,不上 FTS5)
|
||||
/// - `order_by`:白名单枚举(`created_at`/`updated_at`/`priority`/`status`/`score`,
|
||||
/// 见 `validate_idea_order_by`,防 SQL 注入;不进字符串拼接)
|
||||
/// - `limit`/`offset`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)
|
||||
///
|
||||
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
|
||||
/// `Default`:命令层兼容旧 `status` 单参数路径(构造 `IdeaQuery { status, ..Default }`)。
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
pub struct IdeaQuery {
|
||||
pub status: Option<String>,
|
||||
pub keyword: Option<String>,
|
||||
pub order_by: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。
|
||||
///
|
||||
/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。
|
||||
/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、
|
||||
/// status(状态聚合)、score(评分,后端 NULL 视为 0)。
|
||||
fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> {
|
||||
Ok(match col {
|
||||
"created_at" => "created_at",
|
||||
"updated_at" => "updated_at",
|
||||
"priority" => "priority",
|
||||
"status" => "status",
|
||||
"score" => "score",
|
||||
_ => {
|
||||
return Err(df_types::error::Error::Storage(format!(
|
||||
"ideas order_by 不允许的字段名: {col}"
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
/// 想法表 CRUD
|
||||
IdeaRepo,
|
||||
@@ -193,6 +240,109 @@ impl_repo!(
|
||||
|
||||
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
impl IdeaRepo {
|
||||
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
|
||||
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
|
||||
/// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。
|
||||
///
|
||||
/// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。
|
||||
///
|
||||
/// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原
|
||||
/// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)`
|
||||
/// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。
|
||||
pub async fn list_by_query(&self, q: &IdeaQuery) -> Result<Vec<IdeaRecord>> {
|
||||
// 解析 + 校验 order_by(默认 created_at DESC)
|
||||
let order_col = match &q.order_by {
|
||||
Some(o) => validate_idea_order_by(o)?,
|
||||
None => "created_at",
|
||||
};
|
||||
|
||||
let conn = self.conn.clone();
|
||||
|
||||
// 所有按值移动进闭包的量,提前 clone 避免 move 借用问题
|
||||
let status = q.status.clone();
|
||||
let keyword = q.keyword.clone();
|
||||
// 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT)
|
||||
let limit_i: Option<i64> = q.limit.map(|l| (l.min(200)) as i64);
|
||||
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符)
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
|
||||
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
|
||||
let order_expr = if order_col == "score" {
|
||||
"COALESCE(score, 0) DESC".to_string()
|
||||
} else {
|
||||
format!("{order_col} DESC")
|
||||
};
|
||||
|
||||
// LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。
|
||||
let where_param_count = params_vec.len();
|
||||
let limit_sql_bound = match limit_i {
|
||||
Some(_) => format!(
|
||||
" LIMIT ?{} OFFSET ?{}",
|
||||
where_param_count + 1,
|
||||
where_param_count + 2
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}"
|
||||
);
|
||||
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
|
||||
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
|
||||
// limit/offset 也压入 Box<Vec> 收口:借用引用需指向同一生命周期存活处,
|
||||
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。
|
||||
if let Some(l) = limit_i {
|
||||
params_vec.push(Box::new(l));
|
||||
params_vec.push(Box::new(offset_i));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| idea_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
impl KnowledgeRepo {
|
||||
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
|
||||
///
|
||||
|
||||
@@ -15,6 +15,62 @@ use crate::models::{
|
||||
use super::impl_repo;
|
||||
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
// ============================================================
|
||||
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
|
||||
// ============================================================
|
||||
|
||||
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
|
||||
///
|
||||
/// 对齐方案 docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1 统一查询结构。
|
||||
/// 项目表无 status 生命周期(P1 跳过),仅软删 deleted_at(由 list_by_query 固定排除)。
|
||||
/// 故本结构不含 status,只含 keyword/order_by/limit/offset 四维。
|
||||
///
|
||||
/// 序列化 derive:命令层经 serde 从前端 IPC 接收(Option 字段缺省 None,旧调用方不传等价全量)。
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProjectQuery {
|
||||
/// 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词)
|
||||
#[serde(default)]
|
||||
pub keyword: Option<String>,
|
||||
/// 排序字段(白名单校验,格式 "col" / "col asc" / "col desc",默认 created_at desc)
|
||||
#[serde(default)]
|
||||
pub order_by: Option<String>,
|
||||
/// 返回上限(钳制 [0,200],None = 不加 LIMIT 全量)
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
/// 偏移量(None = 0,配合 limit 分页)
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
|
||||
///
|
||||
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name`
|
||||
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
|
||||
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
|
||||
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
||||
let Some(raw) = order_by else {
|
||||
return Ok("created_at DESC".to_string());
|
||||
};
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Ok("created_at DESC".to_string());
|
||||
}
|
||||
// 拆 "col [dir]",最多 2 段。
|
||||
let parts: Vec<&str> = raw.split_whitespace().collect();
|
||||
let col = parts[0];
|
||||
let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
|
||||
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。
|
||||
validate_column_name(col, "projects")?;
|
||||
match dir.as_deref() {
|
||||
None | Some("DESC") => Ok(format!("{col} DESC")),
|
||||
Some("ASC") => Ok(format!("{col} ASC")),
|
||||
// 其它非法方向词:拒绝(防 "; DROP TABLE" 之类拼串绕过)。
|
||||
Some(other) => Err(df_types::error::Error::Storage(format!(
|
||||
"非法排序方向: {other}(仅支持 asc/desc)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
@@ -144,6 +200,74 @@ impl ProjectRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
||||
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
|
||||
///
|
||||
/// - `keyword`:name/description `LIKE %kw%`(对齐知识库 title/content LIKE)。
|
||||
/// - `order_by`:走白名单 `validate_column_name` 防 SQL 注入(列名不可参数化,必须拼字符串,
|
||||
/// 白名单是唯一防注入手段)。默认 `created_at DESC`(与 list_active 等价)。
|
||||
/// - `limit`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`),防恶意/失误传超大值。
|
||||
/// - `offset`:偏移量,配合 limit 做分页。
|
||||
///
|
||||
/// 向后兼容:全字段 None 时 SQL 退化为
|
||||
/// `SELECT ... WHERE deleted_at IS NULL ORDER BY created_at DESC`
|
||||
/// 与 `list_active` 完全等价,零破坏。
|
||||
pub async fn list_by_query(&self, q: ProjectQuery) -> Result<Vec<ProjectRecord>> {
|
||||
// order_by 白名单校验(列名拼字符串前必须校验,防注入)。
|
||||
// 校验后再拼方向:允许 "col" / "col asc" / "col desc"(大小写不敏感)。
|
||||
let order_clause = build_order_clause(q.order_by.as_deref())?;
|
||||
|
||||
let conn = self.conn.clone();
|
||||
// limit 钳制:None 不加 LIMIT(全量,对齐 list_active);Some 钳到 [0,200]。
|
||||
let limit_i: Option<i64> = q.limit.map(|n| n.min(200) as i64);
|
||||
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
||||
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
||||
let mut sql = String::from(
|
||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
|
||||
FROM projects WHERE deleted_at IS NULL",
|
||||
);
|
||||
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
||||
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
if let Some(kw) = &q.keyword {
|
||||
let trimmed = kw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{trimmed}%");
|
||||
params_vec.push(Box::new(pattern.clone()));
|
||||
params_vec.push(Box::new(pattern));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY ");
|
||||
sql.push_str(&order_clause);
|
||||
// 分页:有 limit 才加 LIMIT/OFFSET(全量场景不加,等价 list_active)。
|
||||
if let Some(lim) = limit_i {
|
||||
sql.push_str(" LIMIT ? OFFSET ?");
|
||||
params_vec.push(Box::new(lim));
|
||||
params_vec.push(Box::new(offset_i));
|
||||
}
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| project_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删项目,返回是否命中。
|
||||
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
@@ -37,6 +38,59 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务列表查询入参(F-260621-02 查询维度补全)
|
||||
// ============================================================
|
||||
|
||||
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
|
||||
///
|
||||
/// 设计对齐方案文档(docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1):
|
||||
/// ① 字段全 Option,旧调用方不传 / 传空 → 等价 list_active(全量未删),零破坏;
|
||||
/// ② 复用 KnowledgeRepo::search 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 参数绑定);
|
||||
/// ③ order_by 走白名单(TASK_ORDER_BY_WHITELIST)防 SQL 注入,对齐
|
||||
/// impl_repo! 宏 validate_column_name 的白名单防注入思路;
|
||||
/// ④ limit/offset 钳制上限(limit.min(500))防滥用,对齐 conversation_repo::list_recent
|
||||
/// 的 limit.min(200)(任务场景放宽至 500,数据量 ~15 任务)。
|
||||
///
|
||||
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
|
||||
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TaskQuery {
|
||||
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
|
||||
pub project_id: Option<String>,
|
||||
/// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。
|
||||
pub status: Option<String>,
|
||||
/// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。
|
||||
pub priority: Option<i32>,
|
||||
/// 负责人过滤。P3 基建就绪,视图暂不用。
|
||||
pub assignee: Option<String>,
|
||||
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
|
||||
pub keyword: Option<String>,
|
||||
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
|
||||
pub order_by: Option<String>,
|
||||
/// 分页上限(钳制 ≤500)。P3 基建就绪。
|
||||
pub limit: Option<u32>,
|
||||
/// 分页偏移。P3 基建就绪。
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// order_by 白名单:只允许这些列名拼进 SQL(防注入,列名不可参数化只能白名单)。
|
||||
/// 对齐 impl_repo! 宏 validate_column_name 的「白名单先于拼接校验」防注入思路。
|
||||
/// 注:created_at 是默认值(白名单含它,query 未指定时用 created_at DESC)。
|
||||
const TASK_ORDER_BY_WHITELIST: &[&str] = &["created_at", "updated_at", "priority", "status"];
|
||||
|
||||
/// 校验 order_by 列名在白名单内,否则返回 Err(防 SQL 注入)。
|
||||
fn validate_order_by(field: &str) -> Result<()> {
|
||||
if TASK_ORDER_BY_WHITELIST.contains(&field) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(df_types::error::Error::Storage(format!(
|
||||
"非法 order_by 字段名: {},合法值: {:?}",
|
||||
field, TASK_ORDER_BY_WHITELIST
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现
|
||||
// ============================================================
|
||||
@@ -218,6 +272,127 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
|
||||
///
|
||||
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
|
||||
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
|
||||
/// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行,
|
||||
/// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。
|
||||
///
|
||||
/// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE)
|
||||
/// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search)
|
||||
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
|
||||
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
|
||||
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
|
||||
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||||
///
|
||||
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
|
||||
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
|
||||
/// (非法 status 在 DB 无匹配行,返回空 Vec,无害)。
|
||||
pub async fn list_by_query(&self, query: &TaskQuery) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
|
||||
// order_by 白名单校验在闭包外做(提前 fail-fast,非法值不进 DB 层)。列名不可
|
||||
// 参数化,只能白名单防注入(对齐 impl_repo! 宏 validate_column_name 思路)。
|
||||
// order_col 转 String 拥有所有权:避免 &str 借用 query 跨 spawn_blocking 'static
|
||||
// 闭包(E0521 borrowed data escapes)。
|
||||
let order_col = query.order_by.as_deref().unwrap_or("created_at");
|
||||
validate_order_by(order_col)?;
|
||||
let order_col = order_col.to_string();
|
||||
|
||||
// limit 钳制上限 500 防滥用(对齐 conversation_repo::list_recent 的 limit.min(200)
|
||||
// 思路,任务场景放宽;None → 不拼 LIMIT = 全量,语义同 list_active)。
|
||||
let limit_i: Option<i64> = query.limit.map(|l| (l.min(500)) as i64);
|
||||
let offset_i: i64 = query.offset.unwrap_or(0) as i64;
|
||||
|
||||
// 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求)
|
||||
let project_id = query.project_id.clone();
|
||||
let status = query.status.clone();
|
||||
let priority = query.priority;
|
||||
let assignee = query.assignee.clone();
|
||||
let keyword = query.keyword.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)──
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(pid) = &project_id {
|
||||
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(p));
|
||||
}
|
||||
if let Some(a) = &assignee {
|
||||
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(a.clone()));
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
// LIMIT/OFFSET:limit 为 None → 不拼(全量);有 limit 时 offset 紧跟其后。
|
||||
let where_param_count = params_vec.len();
|
||||
let limit_sql_bound = match limit_i {
|
||||
Some(_) => format!(
|
||||
" LIMIT ?{} OFFSET ?{}",
|
||||
where_param_count + 1,
|
||||
where_param_count + 2
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
|
||||
let sql = format!(
|
||||
"SELECT id, project_id, title, description, status, priority, branch_name, \
|
||||
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
|
||||
created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||
where_clauses.join(" AND "),
|
||||
order_col,
|
||||
limit_sql_bound
|
||||
);
|
||||
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
|
||||
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
|
||||
// limit/offset 也压入 params_vec 收口:借用引用需指向同一生命周期存活处,
|
||||
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597,
|
||||
// 对齐 idea_repo.rs list_by_query 同名模式)。
|
||||
if let Some(l) = limit_i {
|
||||
params_vec.push(Box::new(l));
|
||||
params_vec.push(Box::new(offset_i));
|
||||
}
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| task_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||||
|
||||
@@ -27,8 +27,9 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
|
||||
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
|
||||
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
|
||||
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 25] = [
|
||||
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
|
||||
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 26] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -54,6 +55,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(23, migrate_v23),
|
||||
(24, migrate_v24),
|
||||
(25, migrate_v25),
|
||||
(26, migrate_v26),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -632,6 +634,29 @@ fn migrate_v25(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口)
|
||||
///
|
||||
/// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee),
|
||||
/// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status /
|
||||
/// idx_tasks_project_id 同类过滤维度。当前数据量 ~15(前瞻基建非性能驱动),
|
||||
/// 但索引零成本(SQLite 维护代价极小)且向后续 priority 排序/分配人筛选铺路。
|
||||
///
|
||||
/// 实现选 CREATE INDEX IF NOT EXISTS:原生幂等(新库 V1_SQL 已建则跳过,
|
||||
/// 老库无则补建),对新库与老库均安全,无需 PRAGMA 探测。索引定义须与 V1_SQL 中
|
||||
/// 的同名索引一致(仅 priority/assignee 单列索引)。
|
||||
///
|
||||
/// 注:assignee 列允许 NULL(V1_SQL 未 NOT NULL),SQLite 索引正常包含 NULL 行,
|
||||
/// 不影响 assignee = ? 等值查询命中(过滤掉 NULL 行)。
|
||||
fn migrate_v26(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);\
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)",
|
||||
)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [26])?;
|
||||
tracing::info!("迁移 v26 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -762,6 +787,10 @@ CREATE TABLE IF NOT EXISTS node_executions (
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
-- V26 补建(F-260621-02 索引缺口):priority/assignee 过滤下推索引,新库一次性建;
|
||||
-- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_project_id ON releases(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(workflow_id);
|
||||
";
|
||||
|
||||
18
crates/df-tunnel/Cargo.toml
Normal file
18
crates/df-tunnel/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "df-tunnel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "本地 DevFlow 桌面端出站 WS 隧道客户端(穿 NAT 连云后端 df-relay)"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# WS 客户端(tungstenite 底层 + tokio 异步封装)
|
||||
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
43
crates/df-tunnel/src/error.rs
Normal file
43
crates/df-tunnel/src/error.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! df-tunnel 错误类型
|
||||
//!
|
||||
//! 设计:单一 TunnelError 覆盖 WS 连接/收发/序列化/鉴权四类失败,
|
||||
//! thiserror 派生保留错误链(`#[from]` 自动 From)。
|
||||
//! 脚手架阶段仅定义类型,不涉及具体错误码细分(Phase2 填充)。
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// 隧道客户端运行期错误
|
||||
///
|
||||
/// 覆盖范围:
|
||||
/// - 连接建立失败(网络不可达 / TLS 握手 / 鉴权拒绝)
|
||||
/// - 收发失败(底层 WS 帧 / 序列化 / 反序列化)
|
||||
/// - 协议层错误(未预期的消息类型 / 配对绑定失败)
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TunnelError {
|
||||
/// WS 连接建立或断线重连失败
|
||||
#[error("隧道连接失败: {0}")]
|
||||
Connect(String),
|
||||
|
||||
/// 底层 tungstenite WS 协议错误
|
||||
#[error("WS 协议错误: {0}")]
|
||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
|
||||
/// 消息序列化/反序列化失败
|
||||
#[error("消息序列化失败: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// 云后端鉴权/配对拒绝(token 无效 / device 未绑定)
|
||||
#[error("鉴权/配对失败: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// 隧道未连接(对 send/recv 的前置状态校验失败)
|
||||
#[error("隧道未连接")]
|
||||
NotConnected,
|
||||
|
||||
/// 其他未归类错误(降级通道,Phase2 视需要细分)
|
||||
#[error("隧道内部错误: {0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// 模块级 Result 别名,简化调用方签名
|
||||
pub type Result<T> = std::result::Result<T, TunnelError>;
|
||||
146
crates/df-tunnel/src/events.rs
Normal file
146
crates/df-tunnel/src/events.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
//! df-tunnel 隧道事件类型
|
||||
//!
|
||||
//! 设计依据:设计文档「三、技术选型」指出 AiChatEvent 17 变体在两端同语言保证类型一致。
|
||||
//! 但 AiChatEvent 定义在 src-tauri 非 crate 层,跨 crate path 引用会破坏分层边界
|
||||
//! (lib crate 依赖 src-tauri 二进制不合理)。故此处**独立定义隧道透传子集**:
|
||||
//! - 仅保留跨端高频透传的 4 个变体(TextDelta/ToolCall/Approval/Completed),
|
||||
//! Phase2 可按需追加,不依赖 src-tauri。
|
||||
//! - 携带 conv_id 字段做多会话路由(对齐 F-09 多会话 conv_id 契约)。
|
||||
//!
|
||||
//! 向后兼容:新增变体只加不减,旧消费方忽略未知变体(serde 配 `#[serde(other)]` 捕获兜底)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 握手身份宣告帧(对齐 df-relay::relay::Hello)
|
||||
///
|
||||
/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
||||
/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
||||
/// 否则 relay 反序列化失败 → 握手被拒。
|
||||
///
|
||||
/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
||||
/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// 客户端类型("device" / "miniapp"),桌面端恒为 Device
|
||||
pub kind: HelloKind,
|
||||
/// 配对绑定的设备 ID
|
||||
pub device_id: String,
|
||||
/// 配对 token(env DF_RELAY_TOKEN 同源)
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Hello.kind 传输表示(serde 字符串,与 df-relay::ClientKindWire 一致)
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HelloKind {
|
||||
Device,
|
||||
Miniapp,
|
||||
}
|
||||
|
||||
/// relay 控制面错误帧(握手失败时 relay 发送)
|
||||
///
|
||||
/// relay 在校验失败/连接异常时发 `{"kind":"control","error":"<reason>"}` 文本帧。
|
||||
/// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayControlError {
|
||||
/// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段)
|
||||
pub kind: String,
|
||||
/// 错误原因(handshake_failed / kind_mismatch / auth_failed)
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// 隧道透传的事件类型(AiChatEvent 简化子集)
|
||||
///
|
||||
/// 变体语义对齐桌面端 ai-chat-event:
|
||||
/// - `TextDelta`:流式增量文本块
|
||||
/// - `ToolCall`:工具调用请求(含工具名/参数)
|
||||
/// - `Approval`:审批请求(用户授权确认)
|
||||
/// - `Completed`:单轮对话完成
|
||||
///
|
||||
/// Phase2 可扩展 Error/Stopped/Regenerated 等变体。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TunnelEvent {
|
||||
/// 流式文本增量
|
||||
TextDelta {
|
||||
/// 会话 ID(多会话路由用,对齐 F-09)
|
||||
conv_id: String,
|
||||
/// 增量文本片段
|
||||
delta: String,
|
||||
},
|
||||
|
||||
/// 工具调用请求
|
||||
ToolCall {
|
||||
/// 会话 ID
|
||||
conv_id: String,
|
||||
/// 工具调用唯一标识(用于回填结果)
|
||||
call_id: String,
|
||||
/// 工具名
|
||||
name: String,
|
||||
/// 工具参数(原始 JSON,由消费方按 name 解析)
|
||||
arguments: serde_json::Value,
|
||||
},
|
||||
|
||||
/// 审批请求(工具/操作需用户授权)
|
||||
Approval {
|
||||
/// 会话 ID
|
||||
conv_id: String,
|
||||
/// 审批项唯一标识
|
||||
approval_id: String,
|
||||
/// 审批描述(展示给用户)
|
||||
description: String,
|
||||
},
|
||||
|
||||
/// 对话单轮完成
|
||||
Completed {
|
||||
/// 会话 ID
|
||||
conv_id: String,
|
||||
/// 本轮产生的消息 ID
|
||||
message_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TunnelEvent {
|
||||
/// 取事件所属会话 ID(路由分发的便利方法)
|
||||
pub fn conv_id(&self) -> &str {
|
||||
match self {
|
||||
TunnelEvent::TextDelta { conv_id, .. }
|
||||
| TunnelEvent::ToolCall { conv_id, .. }
|
||||
| TunnelEvent::Approval { conv_id, .. }
|
||||
| TunnelEvent::Completed { conv_id, .. } => conv_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
|
||||
///
|
||||
/// 对齐设计文档「Layer1 指令路由」:
|
||||
/// send/stop/approve/regenerate/switch 五类操作。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TunnelCommand {
|
||||
/// 发送消息
|
||||
Send { conv_id: String, content: String },
|
||||
/// 停止当前生成
|
||||
Stop { conv_id: String },
|
||||
/// 审批通过/拒绝(approval_id + accepted)
|
||||
Approve { conv_id: String, approval_id: String, accepted: bool },
|
||||
/// 重新生成
|
||||
Regenerate { conv_id: String },
|
||||
/// 切换对话
|
||||
Switch { conv_id: String },
|
||||
}
|
||||
|
||||
/// 隧道传输的消息包装(双向)
|
||||
///
|
||||
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
|
||||
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
|
||||
/// 同一 WS 连接复用,靠 kind 标签区分。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "dir", rename_all = "snake_case")]
|
||||
pub enum TunnelMessage {
|
||||
/// 桌面端发出的事件(下行)
|
||||
Event(TunnelEvent),
|
||||
/// 云后端转发的指令(上行,发往桌面端)
|
||||
Command(TunnelCommand),
|
||||
}
|
||||
26
crates/df-tunnel/src/lib.rs
Normal file
26
crates/df-tunnel/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! # df-tunnel
|
||||
//!
|
||||
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
|
||||
//!
|
||||
//! ## 职责
|
||||
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
|
||||
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
|
||||
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
|
||||
//!
|
||||
//! ## 边界
|
||||
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
|
||||
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
|
||||
//!
|
||||
//! ## 模块
|
||||
//! - [`events`]:隧道透传事件/指令/消息包装类型
|
||||
//! - [`tunnel`]:TunnelClient trait + WsTunnelClient 骨架
|
||||
//! - [`error`]:TunnelError 错误类型
|
||||
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod tunnel;
|
||||
|
||||
// 顶层再导出常用项,简化调用方 use
|
||||
pub use error::{Result, TunnelError};
|
||||
pub use events::{Hello, HelloKind, RelayControlError, TunnelCommand, TunnelEvent, TunnelMessage};
|
||||
pub use tunnel::{TunnelClient, WsTunnelClient};
|
||||
547
crates/df-tunnel/src/tunnel.rs
Normal file
547
crates/df-tunnel/src/tunnel.rs
Normal file
@@ -0,0 +1,547 @@
|
||||
//! df-tunnel 隧道客户端(Phase2 完整实现)
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
|
||||
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连。
|
||||
//!
|
||||
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
|
||||
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
|
||||
//! - relay 双因子校验(kind == expected_device + token 匹配),失败发
|
||||
//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack)
|
||||
//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧)
|
||||
//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的
|
||||
//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析)
|
||||
//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage)
|
||||
//!
|
||||
//! ## AiChatEvent 透传原则
|
||||
//! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。
|
||||
//! TunnelEvent 仅是高频子集,扩展变体不破坏向后兼容。
|
||||
//!
|
||||
//! ## 并发模型
|
||||
//! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。
|
||||
//! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。
|
||||
//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 device 端桥接层(src-tauri)。
|
||||
//! `&self` + `Arc<Mutex<...>>` 持连接状态,trait 方法全部 async + 不持 stream 借用。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
use crate::error::{Result, TunnelError};
|
||||
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
|
||||
|
||||
/// 收到 miniapp→device 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
|
||||
///
|
||||
/// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型,
|
||||
/// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。
|
||||
/// 设计为 async 便于调用方执行 Tauri command 异步路由。回调内 panic 会中断收发循环,
|
||||
/// 调用方应自行处理错误(回调返回值忽略,失败由调用方记录)。
|
||||
pub type CommandHandler = Arc<dyn Fn(serde_json::Value) -> futures_util::future::BoxFuture<'static, ()> + Send + Sync>;
|
||||
|
||||
/// 隧道客户端抽象
|
||||
///
|
||||
/// 设计为 trait 而非具体结构,便于:
|
||||
/// 1. 测试用 mock 实现(不依赖真实 WS)
|
||||
/// 2. 未来替换底层实现(如换 quic / sse)不改调用方
|
||||
///
|
||||
/// 状态语义:
|
||||
/// - `connect` 一次建立连接 + spawn 收发循环,重复 connect 视为重连(先 disconnect)
|
||||
/// - `is_connected` 轻量查询,不发心跳
|
||||
/// - `send_event` 非阻塞入队,实际 socket 写入由收发循环异步完成
|
||||
/// - `disconnect` 优雅关闭(Close 帧 + 停 task + 清资源)
|
||||
#[async_trait]
|
||||
pub trait TunnelClient: Send + Sync {
|
||||
/// 主动连接云后端 + 注册指令回调
|
||||
///
|
||||
/// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。
|
||||
/// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。
|
||||
/// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。
|
||||
/// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 device 端桥接层 match cmd 路由。
|
||||
async fn connect(
|
||||
&self,
|
||||
url: &str,
|
||||
device_id: &str,
|
||||
token: &str,
|
||||
on_command: CommandHandler,
|
||||
) -> Result<()>;
|
||||
|
||||
/// 主动断开(优雅 Close 帧 + 释放 task / 通道)
|
||||
async fn disconnect(&self) -> Result<()>;
|
||||
|
||||
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// 发送事件(桌面端 → 云后端 → 小程序)
|
||||
///
|
||||
/// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。
|
||||
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
|
||||
|
||||
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
|
||||
///
|
||||
/// Phase3 阶段2(D2 全 19 变体透传):EventBus subscriber 把 AiChatEvent Value 经此方法
|
||||
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
|
||||
/// 后者保留作高频子集快捷方式(D5 强类型保留)。非阻塞入队,连接断开返 NotConnected。
|
||||
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
|
||||
}
|
||||
|
||||
/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧;
|
||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
/// 重连初始退避
|
||||
const RECONNECT_BASE: Duration = Duration::from_secs(1);
|
||||
/// 重连退避上限
|
||||
const RECONNECT_MAX: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 内部连接状态(disconnect 时 drop 即清资源)
|
||||
struct ConnState {
|
||||
/// 发送队列:send_event 投递 → 收发循环消费写 socket
|
||||
tx: mpsc::UnboundedSender<OutFrame>,
|
||||
/// 收发循环 task handle(disconnect 时 abort)
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// 收发循环出站帧(SendLoop 内部消费)
|
||||
enum OutFrame {
|
||||
/// 业务事件(已序列化 JSON 字符串,run_loop 直接写 socket)
|
||||
///
|
||||
/// send_event(TunnelEvent) 与 send_raw_event(Value) 各自预序列化为 String 入队,
|
||||
/// 统一经此变体写 socket(DRY:序列化在调用侧,run_loop 只负责写)。
|
||||
Event(String),
|
||||
/// 优雅关闭
|
||||
Close,
|
||||
}
|
||||
|
||||
/// 默认 WS 隧道客户端(完整实现)
|
||||
pub struct WsTunnelClient {
|
||||
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
|
||||
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
|
||||
/// 该字段保留以备 Phase3「断线后台自动重连」消费。
|
||||
server_url: Mutex<Option<String>>,
|
||||
device_id: Mutex<Option<String>>,
|
||||
token: Mutex<Option<String>>,
|
||||
/// 当前活跃连接状态(None = 未连接)
|
||||
conn: Mutex<Option<ConnState>>,
|
||||
/// 连接状态标志(与收发循环 task 共享同一 Arc,is_connected 无锁查询;
|
||||
/// task 退出时置 false,connect 时置 true)
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WsTunnelClient {
|
||||
/// 创建未连接的客户端实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
server_url: Mutex::new(None),
|
||||
device_id: Mutex::new(None),
|
||||
token: Mutex::new(None),
|
||||
conn: Mutex::new(None),
|
||||
connected: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前已连接时取一份重连参数(未来自动重连用)
|
||||
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
|
||||
let url = self.server_url.lock().await.clone()?;
|
||||
let device_id = self.device_id.lock().await.clone()?;
|
||||
let token = self.token.lock().await.clone()?;
|
||||
Some((url, device_id, token))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WsTunnelClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TunnelClient for WsTunnelClient {
|
||||
async fn connect(
|
||||
&self,
|
||||
url: &str,
|
||||
device_id: &str,
|
||||
token: &str,
|
||||
on_command: CommandHandler,
|
||||
) -> Result<()> {
|
||||
// 已连接则先清(支持重复 connect 视作重连)
|
||||
if self.connected.load(Ordering::Relaxed) {
|
||||
self.cleanup_conn().await;
|
||||
}
|
||||
|
||||
// 1. 建立 WS 连接
|
||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
|
||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// 2. 发 Hello 握手帧
|
||||
let hello = Hello {
|
||||
kind: HelloKind::Device,
|
||||
device_id: device_id.to_string(),
|
||||
token: token.to_string(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello)?;
|
||||
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
|
||||
TunnelError::Connect(format!("发送 Hello 失败: {e}"))
|
||||
})?;
|
||||
|
||||
// 3. 探测握手结果:HANDSHAKE_PROBE 窗口内
|
||||
// - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因)
|
||||
// - 收到 Close / EOF → 连接被关(返回 Connect 错误)
|
||||
// - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环)
|
||||
// - Ping/Pong 协议帧:忽略继续等
|
||||
let mut first_frame_pending: Option<WsMessage> = None;
|
||||
let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await;
|
||||
match probe {
|
||||
Err(_) => {
|
||||
// 超时无负帧:relay 静默通过(常见路径)
|
||||
tracing::debug!("握手探测窗口无负帧,视为成功");
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(TunnelError::Connect(
|
||||
"握手阶段连接关闭(relay 未响应)".into(),
|
||||
));
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
return Err(TunnelError::Connect(format!("握手 recv 错误: {e}")));
|
||||
}
|
||||
Ok(Some(Ok(msg))) => {
|
||||
first_frame_pending = Some(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验首帧是否为 relay 拒绝帧
|
||||
if let Some(msg) = &first_frame_pending {
|
||||
if let Some(err) = parse_relay_error(msg) {
|
||||
return Err(TunnelError::Auth(format!(
|
||||
"relay 拒绝握手: {}",
|
||||
err.error
|
||||
)));
|
||||
}
|
||||
if matches!(msg, WsMessage::Close(_)) {
|
||||
return Err(TunnelError::Connect("握手被 relay Close".into()));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环
|
||||
*self.server_url.lock().await = Some(url.to_string());
|
||||
*self.device_id.lock().await = Some(device_id.to_string());
|
||||
*self.token.lock().await = Some(token.to_string());
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel::<OutFrame>();
|
||||
// 复用 self.connected 的 Arc,task 退出时置 false 与 self 同步
|
||||
self.connected.store(true, Ordering::Relaxed);
|
||||
let connected_for_task = self.connected.clone();
|
||||
|
||||
let task = tokio::spawn(run_loop(
|
||||
sink,
|
||||
stream,
|
||||
first_frame_pending,
|
||||
rx,
|
||||
on_command,
|
||||
connected_for_task,
|
||||
));
|
||||
|
||||
*self.conn.lock().await = Some(ConnState { tx, task });
|
||||
tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
self.cleanup_conn().await;
|
||||
tracing::info!("WsTunnelClient 已断开");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
async fn send_event(&self, event: TunnelEvent) -> Result<()> {
|
||||
// 序列化提前(OutFrame::Event 持 String),失败返 Serde 错给调用方
|
||||
let json = serde_json::to_string(&event)?;
|
||||
let conn = self.conn.lock().await;
|
||||
let Some(state) = conn.as_ref() else {
|
||||
return Err(TunnelError::NotConnected);
|
||||
};
|
||||
state
|
||||
.tx
|
||||
.send(OutFrame::Event(json))
|
||||
.map_err(|_| TunnelError::NotConnected)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()> {
|
||||
// AiChatEvent Value 序列化为 JSON 字符串入队(与 send_event 共用 OutFrame::Event(String))
|
||||
let json = serde_json::to_string(&payload)?;
|
||||
let conn = self.conn.lock().await;
|
||||
let Some(state) = conn.as_ref() else {
|
||||
return Err(TunnelError::NotConnected);
|
||||
};
|
||||
state
|
||||
.tx
|
||||
.send(OutFrame::Event(json))
|
||||
.map_err(|_| TunnelError::NotConnected)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WsTunnelClient {
|
||||
/// 清理当前连接(Close 帧 + abort task + 置标志)
|
||||
async fn cleanup_conn(&self) {
|
||||
let mut conn = self.conn.lock().await;
|
||||
if let Some(state) = conn.take() {
|
||||
// 通知收发循环优雅关闭(忽略发送失败:循环已退出)
|
||||
let _ = state.tx.send(OutFrame::Close);
|
||||
// 给 task 一点时间自然退出(发完 Close 帧);超时则 abort 兜底
|
||||
let mut task = state.task;
|
||||
tokio::select! {
|
||||
_ = &mut task => {}
|
||||
_ = tokio::time::sleep(Duration::from_millis(500)) => {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.server_url.lock().await = None;
|
||||
*self.device_id.lock().await = None;
|
||||
*self.token.lock().await = None;
|
||||
self.connected.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收发循环主体(spawn 后独立运行)
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. socket 入帧 → 解析 BroadcastMessage → 取 payload Value 纯透传 → on_command 回调(协议解析在桥接层)
|
||||
/// 2. mpsc 出帧 → socket 写入(Event 序列化 / Ping 心跳 / Close 关闭)
|
||||
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
|
||||
/// 4. 任一端断开 → 退出 task,置 connected=false
|
||||
///
|
||||
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
|
||||
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
|
||||
async fn run_loop(
|
||||
mut sink: futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
WsMessage,
|
||||
>,
|
||||
mut stream: futures_util::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
>,
|
||||
first_frame: Option<WsMessage>,
|
||||
mut rx: mpsc::UnboundedReceiver<OutFrame>,
|
||||
on_command: CommandHandler,
|
||||
connected: Arc<AtomicBool>,
|
||||
) {
|
||||
// 心跳 tick
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
// 首次 tick 立即触发会干扰,跳过
|
||||
heartbeat.tick().await;
|
||||
|
||||
// 处理握手期已读但未消费的首帧(可能是首条业务消息)
|
||||
if let Some(msg) = first_frame {
|
||||
if !handle_inbound(&msg, &on_command).await {
|
||||
tracing::info!("首帧处理后连接应关闭,退出收发循环");
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
let _ = sink.close().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// socket 入帧
|
||||
maybe_msg = stream.next() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(msg)) => {
|
||||
if !handle_inbound(&msg, &on_command).await {
|
||||
tracing::info!("对端关闭连接,收发循环退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!(error = %e, "socket recv 错误,收发循环退出");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
tracing::info!("socket 流结束,收发循环退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 出站队列
|
||||
maybe_out = rx.recv() => {
|
||||
match maybe_out {
|
||||
// OutFrame::Event 持已序列化 JSON 字符串(send_event/send_raw_event 预序列化)
|
||||
Some(OutFrame::Event(json)) => {
|
||||
if let Err(e) = sink.send(WsMessage::Text(json)).await {
|
||||
tracing::warn!(error = %e, "socket 写入失败,收发循环退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(OutFrame::Close) => {
|
||||
tracing::debug!("收到 disconnect 指令,发 Close 帧优雅关闭");
|
||||
let _ = sink.send(WsMessage::Close(Some(CloseFrame {
|
||||
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
|
||||
reason: "client_disconnect".into(),
|
||||
}))).await;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("发送队列关闭(客户端 drop),退出收发循环");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 心跳
|
||||
_ = heartbeat.tick() => {
|
||||
if let Err(e) = sink.send(WsMessage::Ping(Vec::new())).await {
|
||||
tracing::warn!(error = %e, "心跳发送失败,收发循环退出");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
// 确保 sink 关闭(若上面 break 未发 Close,这里兜底)
|
||||
let _ = sink.close().await;
|
||||
}
|
||||
|
||||
/// 处理入站帧,返回 false 表示连接应关闭(Close/EOF/错误)
|
||||
async fn handle_inbound(msg: &WsMessage, on_command: &CommandHandler) -> bool {
|
||||
match msg {
|
||||
WsMessage::Text(text) => {
|
||||
// Phase3 纯透传(D1=A):relay 转发完整 BroadcastMessage,
|
||||
// payload 是 miniapp 指令原样 JSON。tunnel 只提取 payload Value 上抛,
|
||||
// 不解析业务协议(协议解析在 device 端桥接层 src-tauri)。
|
||||
if let Some(payload) = parse_payload_from_broadcast(text) {
|
||||
// 回调内执行指令路由(桥接层 match cmd → Tauri command);回调失败不影响收发循环
|
||||
let fut = on_command(payload);
|
||||
fut.await;
|
||||
} else {
|
||||
// 非 Command 消息(Control/Event 回环/未知):忽略,不中断
|
||||
tracing::debug!(text = %text.chars().take(200).collect::<String>(), "收到非 Command 入站消息,忽略");
|
||||
}
|
||||
true
|
||||
}
|
||||
WsMessage::Binary(_) => {
|
||||
tracing::debug!("收到二进制帧,忽略");
|
||||
true
|
||||
}
|
||||
WsMessage::Ping(_) => {
|
||||
// tungstenite 协议层自动应答 Pong,这里不处理
|
||||
true
|
||||
}
|
||||
WsMessage::Pong(_) => true,
|
||||
WsMessage::Close(_) => false,
|
||||
WsMessage::Frame(_) => true, // 原始帧,上层已聚合,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 BroadcastMessage JSON 提取 command 方向的 payload Value(纯透传,不解析业务协议)
|
||||
///
|
||||
/// Phase3 协议统一(D1=A):df-relay 入站包成 `BroadcastMessage { ..., payload: Value }`,
|
||||
/// tunnel 只提取 payload 字段以 Value 上抛 on_command,不反序列化为 TunnelCommand 强类型
|
||||
/// (业务协议解析在 device 端桥接层)。仅 kind=="command" 时提取(Event/Control 不提,避免回环噪音)。
|
||||
fn parse_payload_from_broadcast(raw: &str) -> Option<serde_json::Value> {
|
||||
// 仅取 payload 字段,避整结构强类型耦合(device_id/source 等字段本客户端不关心)
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BroadcastLike {
|
||||
#[serde(default)]
|
||||
payload: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
let parsed: BroadcastLike = serde_json::from_str(raw).ok()?;
|
||||
// 仅 kind == "command" 时才提 payload(Event/Control 不提)
|
||||
if parsed.kind.as_deref() != Some("command") {
|
||||
return None;
|
||||
}
|
||||
parsed.payload
|
||||
}
|
||||
|
||||
/// 判断是否为 relay 控制面错误帧
|
||||
fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
|
||||
if let WsMessage::Text(t) = msg {
|
||||
serde_json::from_str::<RelayControlError>(t).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
|
||||
#[allow(dead_code)]
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
||||
let secs = RECONNECT_BASE
|
||||
.as_secs()
|
||||
.saturating_mul(1u64 << attempt.min(5));
|
||||
Duration::from_secs(secs.min(RECONNECT_MAX.as_secs()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backoff_caps_at_max() {
|
||||
assert_eq!(backoff_delay(0), RECONNECT_BASE);
|
||||
assert_eq!(backoff_delay(1), Duration::from_secs(2));
|
||||
assert_eq!(backoff_delay(5), Duration::from_secs(32).min(RECONNECT_MAX));
|
||||
assert_eq!(backoff_delay(10), RECONNECT_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_from_broadcast_extracts_value() {
|
||||
// 模拟 relay 转发的 BroadcastMessage(miniapp 发的 MiniCommand {cmd, args} 在 payload)
|
||||
let raw = r#"{"device_id":"dev-1","kind":"command","source":{"0":42},"from":"miniapp","payload":{"cmd":"send_message","args":{"message":"hi","conversation_id":"c1"}},"ts":1700000000000}"#;
|
||||
let payload = parse_payload_from_broadcast(raw).expect("应提取出 payload Value");
|
||||
// 纯透传:payload 原样上抛,字段保持 miniapp 端 {cmd, args} 结构(未做协议转换)
|
||||
assert_eq!(payload["cmd"], "send_message");
|
||||
assert_eq!(payload["args"]["message"], "hi");
|
||||
assert_eq!(payload["args"]["conversation_id"], "c1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_ignores_event_kind() {
|
||||
// Event 方向(device→miniapp)不提取,避免回环噪音
|
||||
let raw = r#"{"device_id":"dev-1","kind":"event","source":{"0":1},"from":"device","payload":{"type":"text_delta","conversation_id":"c1","delta":"x"},"ts":1}"#;
|
||||
assert!(parse_payload_from_broadcast(raw).is_none());
|
||||
}
|
||||
|
||||
/// D5 弱校验:payload Value 仍可反序列化为 TunnelCommand(保留强类型作桥接层可选校验)
|
||||
#[test]
|
||||
fn payload_compatible_with_tunnel_command() {
|
||||
use crate::events::TunnelCommand;
|
||||
let raw = r#"{"device_id":"dev-1","kind":"command","source":{"0":42},"from":"miniapp","payload":{"kind":"send","conv_id":"c1","content":"hi"},"ts":1}"#;
|
||||
let payload = parse_payload_from_broadcast(raw).expect("应提取 payload");
|
||||
let cmd: TunnelCommand = serde_json::from_value(payload).expect("payload 应兼容 TunnelCommand");
|
||||
match cmd {
|
||||
TunnelCommand::Send { conv_id, content } => {
|
||||
assert_eq!(conv_id, "c1");
|
||||
assert_eq!(content, "hi");
|
||||
}
|
||||
other => panic!("预期 Send,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_relay_error_detects_auth_failure() {
|
||||
let msg = WsMessage::Text(r#"{"kind":"control","error":"auth_failed"}"#.into());
|
||||
let err = parse_relay_error(&msg).expect("应解析出错误帧");
|
||||
assert_eq!(err.error, "auth_failed");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,15 @@ use std::collections::HashMap;
|
||||
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DagDef {
|
||||
// BUG-260621-01: nodes/edges 加 #[serde(default)]。
|
||||
// 前端 run_workflow 推进链传空 dag `{}`(设计意图:空 dag + target_status →
|
||||
// workflow.rs:91 dag.nodes.is_empty() 进 template_for 自动选模板)。原无 default
|
||||
// 时,`{}` 在 Tauri IPC 入口反序列化 DagDef 即报 `missing field nodes`,挡在选模板
|
||||
// 逻辑之前。加 default 后 `{}` → 空 DagDef(HashMap/Vec 的 Default 均空集合),
|
||||
// 顺利进 :91 选模板分支。非空 dag 零回归(serde default 仅补缺失字段)。
|
||||
#[serde(default)]
|
||||
pub nodes: HashMap<String, NodeDef>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<EdgeDef>,
|
||||
}
|
||||
|
||||
@@ -78,3 +86,28 @@ impl Default for DagDef {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// BUG-260621-01: 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
|
||||
// 成空 DagDef(非报 missing field),后续 workflow.rs:91 dag.nodes.is_empty()
|
||||
// 进 template_for 选模板。nodes/edges 加 #[serde(default)] 前此用例会失败。
|
||||
#[test]
|
||||
fn empty_json_deserializes_to_empty_dagdef() {
|
||||
let dag: DagDef = serde_json::from_str("{}").expect("空 JSON 应反序列化为空 DagDef");
|
||||
assert!(dag.nodes.is_empty());
|
||||
assert!(dag.edges.is_empty());
|
||||
}
|
||||
|
||||
// 零回归:非空 dag 反序列化行为不变(serde default 仅补缺失字段)。
|
||||
#[test]
|
||||
fn nonempty_dag_deserializes_unchanged() {
|
||||
let json = r#"{"nodes":{"n1":{"id":"n1","node_type":"echo","config":{}}},"edges":[]}"#;
|
||||
let dag: DagDef = serde_json::from_str(json).expect("非空 dag 反序列化");
|
||||
assert_eq!(dag.nodes.len(), 1);
|
||||
assert!(dag.nodes.contains_key("n1"));
|
||||
assert!(dag.edges.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
257
docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md
Normal file
257
docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# AST 符号解析 — Code Intelligence 设计
|
||||
|
||||
> 日期:2026-06-24 | 状态:设计深化(2026-06-24:信息密度三态 + 主题压缩),Phase1 待实施
|
||||
> 关联:memory [[devflow-info-density-concept]] / [[devflow-aichat-session-analysis-2026-06-22]] / plan gentle-gliding-book
|
||||
|
||||
## Context(为什么)
|
||||
|
||||
**起因**:实测会话 e46f5605「DevFlow上下文管理不足分」——LLM `read_file` 读 8 个源码文件(context.rs/anthropic_compat/intent.rs/prompt.rs/compress 等)各 500 行**全文回灌**,prompt 累积 **360K token 超 glm-5.2 上下文上限** → LLM 失败 → 末尾 tool 后无 assistant(卡)。8dfe0b94 更滚到 5M token 爆炸。
|
||||
|
||||
**已否决的治标方向**:
|
||||
- ❌ read_file 限制行数(500→150):"等于没读",LLM 被迫 offset 翻页,更多轮次 + 仍累积
|
||||
- ❌ read_file content 字节截断(8KB):"不讲武德",LLM 要全文被砍
|
||||
|
||||
**正路**:AST 语义解析,精准提取**调用链 + 函数代码**,替代物理读全文。
|
||||
|
||||
**双目标**(用户明确):
|
||||
1. 精准获取调用链 + 函数代码,避免物理读文件的 LLM 上下文成本
|
||||
2. AST 树作后期 ai coding 编码支撑工具(符号检索/依赖图/生成上下文/向量编码)
|
||||
|
||||
**根本目标(2026-06-24 深化)**:提高上下文**信息密度**——使进 prompt 的每一行与**当前关注主题**紧密结合,去噪、去多余。这是所有读取/压缩设计的判据。
|
||||
|
||||
## 核心原则:信息密度 ≠ 压缩(2026-06-24)
|
||||
|
||||
**密度 = 只给与当前任务相关的内容;压缩 = 无差别减体积。两者不同。** 压缩减了体积,密度不一定升——减什么是预设规则或盲猜,和"当前任务关注什么"无关。
|
||||
|
||||
**分水岭:压缩器带不带当前任务上下文。**
|
||||
- 不带 → 通用摘要(治标,可能砍掉恰是任务关键的细节)
|
||||
- 带(任务焦点 focus)→ 相关性提炼(治本,留相关去无关)
|
||||
|
||||
**被否的治标方向**:
|
||||
- ❌ 物理行数截断上限(500→150→3):武断,任何值都不合理(3 行砍废、3 万行等于没设);且按行返本身错——该按**语义结构层级**返
|
||||
- ❌ 算法裁剪(砍注释/doc/空行):格式去噪,留下的不一定和任务相关,密度提升有限
|
||||
- ❌ 通用 LLM 摘要(不带 focus):压出"这个函数做什么"的通用摘要,可能丢任务关键行
|
||||
|
||||
**正路(本设计采纳)**:
|
||||
1. **结构层级供给**(拉模式/Progressive Disclosure):read_symbol 默认返骨架 → LLM 看骨架下钻局部 → 精准取。进 prompt 的都是 LLM 主动要的,天然相关。
|
||||
2. **主题驱动压缩**(带 focus):大块必须给时,压缩 LLM 接收当前任务焦点,做相关性提炼(非通用摘要)。
|
||||
|
||||
→ 不按物理行返,按**语义层级**返;压缩必须**带任务上下文**。
|
||||
|
||||
## 一、技术定位:AST vs LSP(两个不同层面)
|
||||
|
||||
| | AST(tree-sitter) | LSP(rust-analyzer) |
|
||||
|---|---|---|
|
||||
| 本质 | 数据结构(语法树,解析产物) | 通信协议(语义服务,JSON-RPC) |
|
||||
| 层面 | 语法层 | 语义层 |
|
||||
| 提供什么 | 符号结构/调用语法 | 类型/精准引用/重构/诊断 |
|
||||
| 形态 | 库(链接进进程) | 独立服务器(子进程) |
|
||||
| 成本 | 轻(ms/MB) | 重(二进制 30-50MB/平台 + 索引 + 内存) |
|
||||
|
||||
抽象层级:源码 → [tree-sitter] → AST(语法,Phase1-3 停此) → [类型推断] → 语义 → [LSP 协议] → 客户端。**LSP 依赖 AST**(rust-analyzer 内部也用),但停在更深(语义层)。
|
||||
|
||||
## 二、目标论证(能力边界,不夸大)
|
||||
|
||||
### 目标1:调用链 + 函数代码
|
||||
|
||||
| 能力 | tree-sitter | Phase |
|
||||
|---|---|---|
|
||||
| 函数结构(骨架·默认) | ✅ `function_definition` → 签名+分支结构+调用图+行数(极小,高密度) | 1 |
|
||||
| 函数代码(定义体) | ✅ `full=true` / `drill` 取完整或局部(语法边界 100% 准) | 1 |
|
||||
| 同文件调用点 | ✅ `call_expression` 命中符号 → 调用位置 | 2 |
|
||||
| 跨文件调用链 | ✅ 符号索引 + import 解析 | 3 |
|
||||
| **动态调度**(`dyn Trait`/虚函数) | ❌ 静态解析不能(需类型推断,LSP 级) | 局限 |
|
||||
| 回调/闭包/函数指针/宏 | ❌ 需数据流/编译器 | 局限 |
|
||||
|
||||
**覆盖度**:静态直接调用占实际 ~70-80%(绝大多数业务代码);动态/间接/宏 ~20-30% 需 LSP。
|
||||
|
||||
**避免上下文成本**:`read_symbol(compress)` → 函数体几十行 + 静态调用点,非 `read_file` 全文 500 行 → prompt 降一个量级。对 e46f5605 类(读源码理解实现,静态为主)直接见效。
|
||||
|
||||
### 目标2:ai coding 编码支撑
|
||||
|
||||
AST 是语法层(parse tree)。**语法层能**:
|
||||
- 符号级上下文(LLM 编码拿相关函数定义+调用关系,非全文 chunk)
|
||||
- 结构化检索(符号/调用链/依赖,替代文本 grep)
|
||||
- 变更影响分析(改函数 X → 影响哪些调用方,精准 review/测试范围)
|
||||
- 代码生成约束(签名/调用语法)
|
||||
|
||||
**语义层不能**(需 LSP/编译器):类型检查/推断、安全跨文件重构、数据流/借用分析。
|
||||
|
||||
→ AST 够 aichat 的"精准读代码 + 调用链 + 上下文 + 影响分析"(95% 读代码场景);深层语义是 LSP 的事。
|
||||
|
||||
## 三、方案:tree-sitter + read_symbol
|
||||
|
||||
### 选型:tree-sitter
|
||||
- GitHub 出品,增量 AST 解析,几十种语言 grammar
|
||||
- Rust 生态 `tree-sitter` + 各语言 grammar crate(编译期静态链接)
|
||||
- 增量解析:文件改只重解变更(<1ms)
|
||||
|
||||
### grammar 策略(2026-06-24 定稿)
|
||||
|
||||
**静态编译 + 集中 lookup**(架构成本最低,详见 [插件机制设计](插件机制-设计-2026-06-24.md)):
|
||||
|
||||
- **不做**动态加载(grammar 编共享库运行期 dlopen)、**不抽** trait(YAGNI,无第二实现不抽象)
|
||||
- grammar 编译期静态链接进二进制(无 ABI 坑 / 无运行时状态 / 无分发负担)
|
||||
- grammar 获取集中到一个普通函数 `grammar_for(ext) -> Language`(未来加动态只改这一点,不返工调用方)
|
||||
|
||||
**Phase1 范围**(覆盖 devflow 源码 + 主流用户项目):
|
||||
|
||||
| 文件类型 | grammar | 备注 |
|
||||
|---|---|---|
|
||||
| `.rs` | tree-sitter-rust | 后端主体 |
|
||||
| `.ts`/`.tsx` | tree-sitter-typescript | 前端 script |
|
||||
| `.js`/`.jsx` | tree-sitter-javascript | |
|
||||
| `.vue` | **借 tree-sitter-typescript** | 切 `<script>` 段喂 ts;template/style 不做符号(Vue 逻辑在 script) |
|
||||
|
||||
**3 个 grammar 覆盖 4 类文件**。Vue 借 TS 省 grammar + 避 SFC 嵌套复杂度。
|
||||
|
||||
**扩展**:加标准语言 = 加 grammar crate + 一张节点映射表(表驱动,~30 行/语言)。小众(C++/Kotlin)按需;动态加载/集成插件化见插件机制设计文档。
|
||||
|
||||
### 三态读取(read_symbol,密度驱动)
|
||||
|
||||
read_symbol **不按物理行返、不截断**,按**语义结构层级**返(消解"截断多少行"争论):
|
||||
|
||||
| 态 | 触发 | 返回 | 密度 |
|
||||
|---|---|---|---|
|
||||
| **骨架**(默认) | `read_symbol(path, symbol)` | 签名 + 内部分支结构 + 调用列表 + 行数(不返函数体) | 极高(极小+全相关) |
|
||||
| **下钻**(LLM 拉) | `read_symbol(..., drill=分支/调用名)` | 该局部分支/调用的精准定义 | 高(LLM 主动要) |
|
||||
| **全文**(显式) | `read_symbol(..., full=true)` | 完整定义体(小函数常态直通) | 全 |
|
||||
|
||||
```
|
||||
read_symbol(path, symbol, kind?, drill?, full?)
|
||||
→ tree-sitter 解析 path,提取 symbol 定义节点
|
||||
→ 默认:返骨架(签名 + 分支结构 + 调用图 + 行数)
|
||||
→ drill=局部名:返该分支/调用的精准定义
|
||||
→ full=true:返完整定义体
|
||||
→ 返回 {symbol, kind, signature, skeleton|drill|def_body, line_range, file_hash, calls?}
|
||||
```
|
||||
|
||||
### 主题压缩(compress_with_focus,大块兜底)
|
||||
|
||||
大块必须整体给时(复杂函数 / 大文件段),经**带焦点压缩**:
|
||||
|
||||
```
|
||||
compress_with_focus(content, focus)
|
||||
→ focus = 当前任务/问题(LLM 显式传)
|
||||
→ LLM 相关性提炼:留与 focus 相关的路径/逻辑,去无关分支/防御/日志
|
||||
→ 返提炼结果 + 原 hash(丢细节时可回取原始)
|
||||
```
|
||||
|
||||
- **focus 必传**(分水岭:不带 focus 的通用摘要被否)
|
||||
- hash 兜底:LLM 可凭 hash 回取原始(类似下钻)
|
||||
- 与现有 `compress_via_llm`:现有压历史对话(P0-1 未真降 prompt),本工具压"工具返回入 prompt 当轮"——扩展触发点非新造
|
||||
|
||||
### 注册 / schema / 兜底
|
||||
- read_symbol 注册 `register_file_tools`(与 read_file/grep 同域)
|
||||
- schema:`object_schema([("path",string,req),("symbol",string,req),("kind",string,opt),("drill",string,opt),("full",bool,opt)])`
|
||||
- handler:捕获 `allowed_dirs`,调 `resolve_workspace_path_with_allowed` 校验(复用 read_file 模式)
|
||||
- RiskLevel::Low(只读)
|
||||
- **不报错底线**(见七):无 grammar / 解析失败 / symbol 未找到 / 任何大小 → 走兜底或正常返,不 panic / 不阻断
|
||||
|
||||
## 四、Phase 路线
|
||||
|
||||
**两条线**:读取层(read_symbol 三态,tree-sitter)/ 压缩层(主题压缩,LLM,与 P0-1)。
|
||||
|
||||
**读取层**(tree-sitter):
|
||||
|
||||
| Phase | 能力 | 成本 | 缓存 |
|
||||
|---|---|---|---|
|
||||
| **1 单文件符号骨架+下钻+全文** | read_symbol 三态 | 低-中 | 实时解析(弃 AST) |
|
||||
| **2 同文件调用点** | drill 跨分支调用图 | 中 | 实时 |
|
||||
| **3 跨文件调用链** | 符号索引 + import | 高 | 符号表内存缓存 |
|
||||
|
||||
**压缩层**(LLM,与 P0-1):
|
||||
|
||||
| Phase | 能力 | 成本 | 缓存 |
|
||||
|---|---|---|---|
|
||||
| **C1 compress_with_focus** | 带 focus 相关性提炼 | 中(LLM 调用) | hash 缓存(同内容不重压) |
|
||||
| **C2 触发管线接入** | 工具返回→压缩→入 prompt | 中 | 阈值(小不压)+ 缓存 |
|
||||
|
||||
**远期**:Encoding(符号检索/依赖图/向量编码)。
|
||||
|
||||
## 五、缓存策略(分情况,对齐 tree-sitter 增量特性)
|
||||
|
||||
**Phase1/2(单文件)**:**情况1 实时解析**
|
||||
- tree-sitter 单文件 ms 级,解析完提取符号后**丢弃 AST**(不缓存)
|
||||
- 每次 read_symbol 实时解析 → 最新最准(零缓存失效)
|
||||
- 速度 ~1-10ms(几千行),增量 <1ms
|
||||
|
||||
**Phase3(跨文件索引)**:**情况2 缓存**
|
||||
- 全项目符号表(file→symbol→pos+签名),累积大
|
||||
- **2a 内存缓存**(devflow ~4000 符号 ~0.5-2MB,内存够)
|
||||
- 大项目(几千文件/几十 MB)转 **2b 文件缓存**(`.devflow/symbols.cache`,LRU 按需加载)
|
||||
- **失效**:文件 mtime/sha256 变 → tree-sitter 增量重解析该文件(<1ms)→ 更新该文件项(不全扫)
|
||||
|
||||
**增量解析是命脉**:让"实时"(情况1)够快、"缓存失效"(情况2)够便宜。
|
||||
|
||||
## 六、估算(devflow 实测基数)
|
||||
|
||||
- 源码:280 文件 / 82,444 行(Rust 142/52.7K,TS 87/12K,Vue 51/17.6K)
|
||||
- 符号:~4000(Rust ~2840 + TS ~1044 + Vue ~510)
|
||||
- 符号表:Phase1 ~400KB / Phase2 ~1MB / Phase3(含调用链)~1.6-2MB
|
||||
- → **内存缓存够**(情况2a)
|
||||
|
||||
## 七、兜底(不报错底线,不破坏现有)
|
||||
|
||||
**底线:任何情况不 panic / 不阻断 / 不因大小失败。**
|
||||
- 无 grammar(语言不支持)/ 解析失败(语法错误)/ symbol 未找到 → 兜底提示 `grep` + `read_file`(offset 精准读)
|
||||
- **任何大小函数** → 正常返(不截断天然不因大小失败;大函数走骨架+下钻/主题压缩,非报错)
|
||||
- read_symbol 失败不阻断(返兜底提示让 LLM 回退 grep)
|
||||
- compress_with_focus 失败 → 回原始内容(LLM 仍能用,降级非阻断)
|
||||
|
||||
## 八、LSP 后置结论(2026-06-24 评估)
|
||||
|
||||
**LSP 成本高,重 tree-sitter 100x**:
|
||||
- 二进制 ~30-50MB/平台(跨平台 3 份,bundle 进安装包体积大)
|
||||
- 首次索引 30s-2min(全项目类型推断),内存几百 MB-1GB,后台常驻
|
||||
- JSON-RPC 通信复杂(初始化/能力协商/文件同步/多语言 server 生命周期)
|
||||
- 分发:bundle 重 / 要求用户装(rustup/tsserver)门槛高
|
||||
|
||||
**收益**:语义层(动态调度/类型/重构/精准引用),tree-sitter 天花板外 ~20%。
|
||||
|
||||
**结论**:**Phase1-3 tree-sitter 先**(语法层,覆盖 ~80% 读代码/调用链/上下文,轻量零分发负担);**LSP 后置/按需**——检测用户装了 rust-analyzer 才启用深层语义,不强制 bundle,保分发轻。别一上来 LSP(成本/分发过重),tree-sitter 拿 80% 红利,LSP 留作"深层语义可选增强"。
|
||||
|
||||
## 九、与现有方向的关系(密度驱动,四层互补)
|
||||
|
||||
```
|
||||
① 结构层级供给(read_symbol 骨架→下钻) ← 本设计,语义结构精准+密度
|
||||
↓ 大块兜底
|
||||
② 主题压缩(compress_with_focus) ← 带 focus 相关性提炼(非通用摘要)
|
||||
↓ 兜底
|
||||
③ grep+offset(read_file) ← 现有,文本精准
|
||||
↓ 历史治理
|
||||
④ 历史压缩(P0-1 compress_via_llm) ← prompt 层已读历史摘要
|
||||
```
|
||||
|
||||
- LLM 默认 read_symbol 骨架 → 要细节下钻 → 大块经主题压缩 → 不支持回退 grep
|
||||
- 已读历史由 P0-1 压缩
|
||||
- **密度判据**:①②③④ 每层都服务"只给任务相关内容",非"减体积"
|
||||
|
||||
## 十、风险 / 改动面
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| tree-sitter 依赖体积 | 按语言按需 enable grammar(非全装) |
|
||||
| 跨语言覆盖(小众语言 grammar 弱) | 兜底 grep+read |
|
||||
| 动态调度解析不准 | 文档标注局限,需时评估 LSP(后置) |
|
||||
| Phase3 跨文件索引重 | 渐进,Phase1/2 先(实时,无索引) |
|
||||
|
||||
## 十一、验证(Phase1 落地后)
|
||||
|
||||
- **解析速度**:tree-sitter 解析 devflow 大文件(context.rs 1552 行等),测 ms
|
||||
- **符号表大小**:全项目解析后序列化字节数(验估算 ~400KB)
|
||||
- **token 对比**:read_symbol(compress) vs read_file(context.rs) 返回 token 量(验降一个量级)
|
||||
- **单测**:read_symbol 提取函数定义体 + 调用点(Phase2)
|
||||
|
||||
## 十二、落地步骤
|
||||
|
||||
1. **本文档**(设计定稿)
|
||||
2. **Phase1 实施**:tree-sitter-rust 集成 + read_symbol 工具 + 单文件符号提取 + 兜底 + 实测
|
||||
3. Phase2/3/4 + LSP 后置评估,按需
|
||||
|
||||
## 关键文件(Phase1 实施)
|
||||
|
||||
- `src-tauri/Cargo.toml`:加 tree-sitter + tree-sitter-rust
|
||||
- `src-tauri/src/commands/ai/tool_registry.rs`:`register_file_tools` 内加 read_symbol(对齐 read_file handler)
|
||||
- 新模块 `src-tauri/src/commands/ai/code_intel.rs`:tree-sitter 解析 + 符号提取纯函数
|
||||
- 单测:符号提取 + 兜底
|
||||
156
docs/02-架构设计/专项设计/aichat体验与agent能力系统化重构-2026-06-21.md
Normal file
156
docs/02-架构设计/专项设计/aichat体验与agent能力系统化重构-2026-06-21.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# aichat 体验与 agent 能力系统化重构
|
||||
|
||||
> 2026-06-21 | 不破不立 · 长久治本 · 机制优先说教
|
||||
|
||||
## 0. 背景:为什么是系统化重构
|
||||
|
||||
7-agent UX 诊断 + kms 会话(8f607aa4,53 轮 0 产出)实证:**用户难受不是几个 bug,是失控感 + 黑箱感叠加**。诊断出 5 个高痛,但均匀修 5 痛 = 补丁堆砌([[no-patch-groundwork]] 元原则禁止)。
|
||||
|
||||
反思收敛:**DevFlow 给了 AI 一双手(工具),没给一双眼(环境感知)和一个刹车(止损求助)**。根是 **agent 元能力(感知+决策+求助)整体缺失**,表是 UX(降噪/控制/反馈/密度)。
|
||||
|
||||
**系统性解法**:立三层架构,L2/L3 已有设计待整合落地,L1 新立。5 痛作每层第一个消费场景驱动实现。每个 fix 是架构一块砖(可累积/可演进/可观测),非独立补丁。
|
||||
|
||||
## 1. 三层架构
|
||||
|
||||
| 层 | 职责 | 机制(代码强制,非 prompt 说教) | 现状 |
|
||||
|---|---|---|---|
|
||||
| **L1 agent 元能力层** | AI 看得见路、刹得住车、会求助 | 环境探测工具 + 断路器 + 求助协议 | **新立**(四空壳之一,[[aichat-arch-extensibility]]) |
|
||||
| **L2 统一状态机** | 状态收敛,前后端同一真相 | generating/stopping/error enum 视图 + guard 写收敛 | 已设计([[devflow-generating-statemachine]] `generating状态机加固-2026-06-15.md`),待落地 |
|
||||
| **L3 事件总线可观测性** | 进度/卡点/失败/求助统一可见 | pub-sub + request-reply + 流式统一总线 | 已设计([[global-event-bus-design]] `全局事件数据总线-2026-06-21.md`),推进 C |
|
||||
|
||||
**核心原则**([[ai-improvement-principles]]):**机制优先 prompt 说教**。断路器不靠"教 AI 别重试"(LLM 不听),靠代码强制熔断;环境感知不靠"prompt 写死 Windows 规则"(过时),靠动态探测工具。
|
||||
|
||||
## 2. L1 agent 元能力层(新立 · 核心)
|
||||
|
||||
L1 是根。三个组件:
|
||||
|
||||
### 2.1 环境感知(给 AI 一双眼)
|
||||
|
||||
**问题**:`prompt.rs:44 env_info_line()` 只注入"日期+OS"(25 token),AI 不知道 cmd 吃 `$`、PowerShell 才靠谱、python 路径在哪、编码 GBK/UTF-8。kms seq26-52 引号地狱根因。
|
||||
|
||||
**机制**(预防式 + 补救式 + 自省式三层):
|
||||
|
||||
- **预防式 — 启动详细环境注入**:扩 `env_info_line` 为 `env_profile`,启动注入:OS + 默认 shell 类型(Win→PowerShell / Linux·Mac→bash)+ python/node 路径 + 终端编码 + **平台陷阱提示**(Win: cmd 吃 `$/引号,复杂脚本写文件用 `powershell -File` / `python file`;Linux·Mac: bash 引号规则)。约 +120 token,消除引号地狱类系统性错误。
|
||||
- **补救式 — `detect_environment` 工具**:AI 可主动调,返回详细环境(shell/编码/可用命令/Python 版本)。失败 N 次后**自动触发**(见 2.2 断路器),把结果注入 context。
|
||||
- **自省式 — 环境画像缓存**:探测结果缓存(会话级 `env_profile`),避免重复探测;关键环境变化(如 shell 切换)刷新。
|
||||
|
||||
**跨设备**:env_profile 动态 `cfg!` 分支 + 运行时探测,平台无关。Win/Linux/Mac 各自正确姿势。
|
||||
|
||||
**兜底**:探测失败回退静态 env_profile(编译期 OS + 默认 shell),不阻断。
|
||||
|
||||
**开关**:`env_probe_enabled`(默认 on),off 则只静态注入。
|
||||
|
||||
### 2.2 断路器(给 AI 一个刹车)
|
||||
|
||||
**问题**:agentic loop 仅 `max_iterations=10` 硬截断,无智能止损。kms write_file 连撞 8 次、run_command 引号失败 20+ 次才到上限。路径防护错误 AI 视作普通错误反复换法。
|
||||
|
||||
**机制**(代码强制熔断,非说教):
|
||||
|
||||
- **同 tool_call 连续失败 ≥2 次**:熔断,强制停该工具,发求助。
|
||||
- **同路径连撞 ≥2 次**:熔断(路径错误不可绕过,AI 换工具也撞)。
|
||||
- **同错误类型连续 ≥3 次**:熔断(如引号错误反复)。
|
||||
- **熔断动作**:停 loop → 发 `AiHelpRequired` 事件(2.3)→ 前端求助卡(而非 max_iterations 默默截断)。
|
||||
- **错误分类**:路径防护错误标 `fatal_unbypassable`(AI 可操作提示:"路径 X 不在授权区,此错误不可换工具绕过,请改路径或 bind_directory")。
|
||||
|
||||
**开关**:`circuit_breaker_enabled`(默认 on)+ 阈值可调(`max_consecutive_fail=2`)。
|
||||
|
||||
**兜底**:断路器误判 → 用户在求助卡选"继续重试"可覆盖熔断(人工 > 机制)。
|
||||
|
||||
### 2.3 求助协议(让 AI 会求助)
|
||||
|
||||
**问题**:AI 卡死只靠 max_iterations 截断或用户手动打断,无主动求助。prompt 教"失败就问用户"是说教,不可靠。
|
||||
|
||||
**机制**(结构化求助通道):
|
||||
|
||||
- **`AiHelpRequired` 事件**:agent 断路器触发 / 自省(识别"我反复失败")时发,结构化字段:`reason`(死循环/环境未知/权限不足)+ `context`(已试 N 次/卡在哪)+ `options`([换策略/授权路径/人工接管])。
|
||||
- **前端求助卡**:显式 UI(非隐藏),用户选 option → 注入 context → loop 续跑或停。
|
||||
- **与审批区分**:审批=工具执行前确认;求助=AI 主动"我搞不定"。两套机制不混。
|
||||
|
||||
**开关**:`help_required_enabled`(默认 on)。
|
||||
|
||||
## 3. L2 统一状态机(整合现有设计)
|
||||
|
||||
落地 `generating状态机加固-2026-06-15.md`:guard 写收敛 + enum 视图读收敛。
|
||||
|
||||
- **单一真相**:`ConvState` enum(`Idle/Generating/Stopping/Error/Compressed`),消 streaming/generating/isStopping 多源割裂。
|
||||
- **前端读 enum 视图**:停止按钮三态(可停/停中/停失败可重试)是 `Stopping` 状态自然产物,非加变量。
|
||||
- **MaxRoundsCard 判断改 enum**:达轮次时 `Generating` 态可靠弹"继续/停止"卡(诊断痛②:看门狗误清 streaming 致卡片不弹的根解)。
|
||||
|
||||
## 4. L3 事件总线可观测性(整合现有设计)
|
||||
|
||||
落地 `全局事件数据总线-2026-06-21.md`:进度/卡点/失败/求助/心跳统一事件,前端订阅渲染。
|
||||
|
||||
- **心跳 UI 化**:复用 `AiHeartbeat`,每 10-15s 显"AI 已思考 Xs"(诊断痛③:130s 盲等)。
|
||||
- **轮次可视化**:`AiAgentRound` → "轮次 X/Y"(Y=max_iterations 或 ∞)。
|
||||
- **工具分级**:30s 前 10s 预警(橙),30s 后红 + toast。
|
||||
- **审批倒计时**:剩余 X 分钟,临 1 分钟红。
|
||||
- **求助消费**:`AiHelpRequired`(L1)→ 求助卡(L3 渲染)。
|
||||
|
||||
## 5. 5 痛 → 架构组件映射(消费场景驱动)
|
||||
|
||||
| 诊断痛 | 眼前补丁(弃) | 长久架构组件 | 阶段 |
|
||||
|---|---|---|---|
|
||||
| ① AI 死循环零交付 | prompt 教止损 + 折叠 | **L1 断路器 + 环境探测 + 求助协议** | 1 |
|
||||
| ② 停止割裂/卡片不弹 | 加 isStopping | **L2 状态机**(三态是其产物) | 2 |
|
||||
| ③ 无进度反馈 | 加心跳动画 | **L3 事件总线**(心跳是事件之一) | 3 |
|
||||
| ④ 审批疲劳 | 分组按钮 | **L1 会话信任目录机制** + L3 批量事件 | 1/3 |
|
||||
| ⑤ 基础硌人 | 各自修 | L2 状态驱动渲染(统一,非散修) | 3 |
|
||||
|
||||
## 6. 实施顺序(分阶段,每阶段独立可回退)
|
||||
|
||||
**阶段1 · 立 L1 根**(治痛①,最痛):
|
||||
- 环境感知:`env_profile` 注入 + `detect_environment` 工具 + 缓存
|
||||
- 断路器:agentic loop 熔断(同 tool_call/路径/错误类型)
|
||||
- 求助协议:`AiHelpRequired` 事件 + 前端求助卡
|
||||
- 会话信任目录(L1):首次授权后同会话自动放行(治痛④一部分)
|
||||
|
||||
**阶段2 · 立 L2 状态机**(治痛②):
|
||||
- 落地 `ConvState` enum + guard 收敛
|
||||
- 停止三态 + MaxRoundsCard 改 enum 判断
|
||||
|
||||
**阶段3 · 立 L3 事件总线 + 前端消费**(治痛③④⑤):
|
||||
- 事件总线统一输出(进度/卡点/失败/求助/心跳)
|
||||
- 前端订阅渲染:心跳/轮次/工具分级/审批倒计时/求助卡
|
||||
- 降噪:失败折叠 + 同类聚类(x3 点击展开)
|
||||
- 基础:滚动锁存 / 代码围栏占位 / Shift+Enter hint
|
||||
|
||||
每阶段:**独立可回退 + 开关 + 兜底**。阶段1 不依赖 2/3 可先落地见效。
|
||||
|
||||
## 7. 跨设备约束(硬约束)
|
||||
|
||||
| 组件 | 跨设备要求 |
|
||||
|---|---|
|
||||
| L1 env_profile | 动态 `cfg!` + 运行时探测,Win→PowerShell / Linux·Mac→bash,平台陷阱各自注入 |
|
||||
| L1 断路器/求助 | 纯逻辑,平台无关 |
|
||||
| L2 状态机 | 纯逻辑,平台无关 |
|
||||
| L3 事件总线 | 纯逻辑 + 前端,平台无关 |
|
||||
| 路径处理 | 已 `std::path` + workspace_root 归一化 |
|
||||
|
||||
本机跨平台(Win/Linux/Mac)先保证;跨设备同步(手机/多端)是 `df-relay/df-miniapp` 云后端路线([[cross-end-rust-backend]]),另立。
|
||||
|
||||
## 8. 兜底 / 开关 / 可回退([[ai-improvement-principles]])
|
||||
|
||||
每机制配:
|
||||
- **开关**:`env_probe_enabled` / `circuit_breaker_enabled`(+阈值)/ `help_required_enabled`,默认 on,可关。
|
||||
- **兜底**:探测失败→静态 profile;断路器误判→人工覆盖;求助不发→回退 max_iterations。
|
||||
- **可回退**:每阶段独立 git 可 revert;开关 off 即降级旧行为。
|
||||
|
||||
## 9. 关联现有设计(整合不重复)
|
||||
|
||||
- L2 → `generating状态机加固-2026-06-15.md`(直接落地)
|
||||
- L3 → `全局事件数据总线-2026-06-21.md`(直接落地)
|
||||
- L1 决策 → `条件表达式引擎-2026-06-15.md` + `Agent架构说明-2026-06-14.md`(四空壳 coordinator/planner,元能力是其具体化)
|
||||
- 痛①实证 → `aichat-技术债审查-2026-06-21.md` + kms 会话 8f607aa4
|
||||
|
||||
## 10. 验收
|
||||
|
||||
- kms 类场景(白名单外整理):AI 撞 ≤2 次即求助(非 53 轮 0 产出)
|
||||
- 引号地狱:env_profile 注入后 AI 用对 shell / 写文件执行,不反复 -c 内联
|
||||
- 停止按钮:三态可靠,打断后状态干净
|
||||
- 进度:任意时刻用户知 AI 在哪/卡哪/还要多久
|
||||
- 审批:会话信任目录后同路径不重复弹
|
||||
- 跨设备:Win/Linux/Mac env_profile 各自正确
|
||||
|
||||
---
|
||||
|
||||
**下一步**:本设计过目 → 阶段1(L1 根)开整改 workflow 实施。L1 是根,先立见效。
|
||||
108
docs/02-架构设计/专项设计/插件机制-设计-2026-06-24.md
Normal file
108
docs/02-架构设计/专项设计/插件机制-设计-2026-06-24.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 插件机制设计 — DevFlow 可扩展性
|
||||
|
||||
> 日期:2026-06-24 | 状态:方向定稿(grammar 静态 + 集成渐进,通用插件远期)
|
||||
> 关联:[AST 符号解析设计](AST符号解析-设计-2026-06-24.md) / memory devflow-ast-code-intel-design / devflow-product-positioning / no-patch-groundwork
|
||||
|
||||
## Context(为什么有这文档)
|
||||
|
||||
grammar 动态加载讨论 → 引出"插件机制"概念 → 评估通用插件系统 → 落到"外部 API 集成(云效/禅道)"真场景 → 用"架构成本"统一评估。本文档沉淀可扩展性决策:何时插件化、怎么渐进、什么是正确切入点。
|
||||
|
||||
## 一、动态加载 grammar(技术可行性)
|
||||
|
||||
tree-sitter grammar 是 C 解析器,可编译成共享库运行期加载:
|
||||
- grammar → `.dll`/`.so`/`.dylib`(tree-sitter CLI 或 cc 编译)
|
||||
- Rust `libloading` 运行期 dlopen + 查 `tree_sitter_<lang>` 符号 → `Language`
|
||||
- Helix / Neovim / Zed 行业先例
|
||||
|
||||
**但**:跨平台分发(每平台 × 每 grammar 共享库)+ ABI 版本管理(grammar 编译时 tree-sitter 版本须匹配运行期,错则崩)+ 加载机制 ~100-200 行。复杂度中-高。
|
||||
|
||||
## 二、插件形态谱系(窄 → 通用)
|
||||
|
||||
| 形态 | 契约 | 能力 | 复杂度 | 例子 |
|
||||
|---|---|---|---|---|
|
||||
| grammar 动态加载 | 单(一个函数) | 单(源码→AST) | 中 | Helix language |
|
||||
| 集成插件 | 单(IntegrationProvider 几操作) | 中(API 调用+映射+同步) | 中 | 云效/禅道 |
|
||||
| 通用插件系统 | 多(extension points) | 任意(命令/UI/规则...) | 极高 | VSCode 扩展 |
|
||||
|
||||
通用插件要:manifest / 生命周期 / 沙箱 / 权限 / 依赖图 / 市场 / API 稳定性。软件工程最难子系统之一。
|
||||
|
||||
## 三、复杂度 = 架构成本(评估框架)
|
||||
|
||||
复杂度不是抽象的"难",是**架构投资决策**。每个抽象/机制/插件点付架构成本,价值须覆盖:
|
||||
|
||||
| 成本 | 性质 |
|
||||
|---|---|
|
||||
| 抽象边界 | 间接层,理解/调试;抽象错束缚未来 |
|
||||
| 契约维护 | 接口定义了就要养(兼容枷锁) |
|
||||
| 运行时状态 | 故障模式 ×N |
|
||||
| 耦合传播 | 改一处影响多少 |
|
||||
| 演化阻力 | 架构固化后改方向成本(锁死) |
|
||||
| 认知 | 上手成本 |
|
||||
|
||||
**持续 + 复利**:每个未来改动都付。投资回报判断。统一 [[no-patch-groundwork]](成本不被覆盖=补丁堆砌)/ [[ai-improvement-principles]](易迭代=架构成本低)。
|
||||
|
||||
## 四、三方案评估(grammar 视角)
|
||||
|
||||
| 方案 | 架构成本 | 价值 | 当前覆盖? |
|
||||
|---|---|---|---|
|
||||
| **静态 + 集中 lookup** | 最低 | 中 | ✅ 覆盖 |
|
||||
| grammar 动态加载 | 中高(运行时状态+ABI+分发) | 中(红利未兑现) | ❌ |
|
||||
| 通用插件系统 | 极高(沙箱/契约/演化枷锁) | 高(但无生态) | ❌ |
|
||||
|
||||
**决策:静态 + 集中 lookup**。grammar 获取集中到普通函数 `grammar_for(ext)`,不抽 trait(YAGNI,无第二实现不抽象)。
|
||||
|
||||
## 五、外部 API 集成:插件正确切入点
|
||||
|
||||
通用插件当前不做,但**集成是插件能力的正确起点**——真实高价值场景 + 低复杂度形态。
|
||||
|
||||
**需求**:DevFlow 工作流枢纽,任务/缺陷/需求也在云效/禅道 → 数据孤岛 + 重复录入。打通 = 双向同步 + aichat 跨系统操作。
|
||||
|
||||
**技术可行**:云效(阿里云 OpenAPI)/ 禅道(ZenTao REST)都有 API。Rust reqwest 调用,难度低于 LSP/沙箱插件。
|
||||
|
||||
**集成 vs 通用插件**:集成是"窄插件"(单契约 IntegrationProvider,无沙箱——受控 API 调用非执行任意代码)。有扩展红利,无重框架复杂度。
|
||||
|
||||
**架构成本(诚实)**:主要在**同步语义**(双向 DevFlow↔云效:冲突/时序/幂等/一致性,分布式级难题),非插件框架。其他:认证/凭证(中)、数据映射(中,每集成一套)、API 边界稳定性(中,外部依赖持续)。
|
||||
|
||||
**价值**:自用(省重复,aichat 跨系统)+ 分发(集成生态=差异化/粘性/护城河)。成本高但价值高,值得——同步是核心难点,单向起步。
|
||||
|
||||
## 六、渐进路径
|
||||
|
||||
```
|
||||
1. 静态 grammar + 集中 lookup(现在,Phase1)
|
||||
Rust/TS/JS/Vue(借TS),3 grammar 覆盖 4 类
|
||||
不动态/不 trait
|
||||
|
||||
2. 首个集成:云效(近期,memory 已有 yunxiao 同向)
|
||||
直接代码实现,验证打通价值
|
||||
单向同步起步(避双向冲突复杂度)
|
||||
|
||||
3. 抽 IntegrationProvider trait(云效跑通后)
|
||||
标准接口:sync_tasks/list_bugs/create_requirement
|
||||
窄契约,低架构成本
|
||||
|
||||
4. 第二集成:禅道按接口实现(中期)
|
||||
印证抽象可复用
|
||||
|
||||
5. 插件化(远期,集成 3+)
|
||||
动态加载/分发;通用插件系统:产品有规模/生态后
|
||||
```
|
||||
|
||||
每步独立有价值,不依赖后续。先具体后抽象,避免预先建空框架。
|
||||
|
||||
## 七、决策汇总
|
||||
|
||||
| 项 | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| grammar 加载 | 静态 + 集中 lookup | 架构成本最低,红利未兑现 |
|
||||
| grammar trait | 不抽(YAGNI) | 无第二实现不抽象 |
|
||||
| 通用插件系统 | 远期(有生态后) | 当前无生态,过早=负债 |
|
||||
| 集成方向 | 定(云效/禅道,工作流打通) | 真实高价值,窄插件正确起点 |
|
||||
| 集成实现 | 渐进(云效→trait→禅道) | 先具体后抽象 |
|
||||
| 集成同步 | 单向起步 | 双向冲突分布式难题,远期 |
|
||||
|
||||
**核心**:插件愿景对(可扩展=产品长期价值),但时机/复杂度决定——从集成低复杂度高价值场景切入,静态 grammar 当前够,通用插件远期。不为假想需求建重框架。
|
||||
|
||||
## 关键文件(集成实施时)
|
||||
|
||||
- grammar 集成:`src-tauri/Cargo.toml` 加 tree-sitter + grammar crate;新模块 `code_intel.rs` 内 `grammar_for(ext)` 集中 lookup
|
||||
- 集成(云效):新 crate `df-integration` 或 df-execute 内;IntegrationProvider trait;凭证存储复用 KV
|
||||
114
docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md
Normal file
114
docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# F-260621-02 查询能力维度补全方案(任务/项目/灵感)
|
||||
|
||||
> **状态**:方案设计(待办登记),待排期实施
|
||||
> **日期**:2026-06-21
|
||||
> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全
|
||||
> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化)
|
||||
> **本轮排查结论**:本轮仅登记待办,不实施代码。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点
|
||||
|
||||
### 1.1 详情查询(已齐全,无需动)
|
||||
|
||||
| 实体 | 详情接口 | 位置 |
|
||||
|------|---------|------|
|
||||
| 任务 | `get_task_by_id` | `src-tauri/src/commands/task.rs:58` |
|
||||
| 项目 | `get_project(id)` | `src-tauri/src/commands/project.rs:193` |
|
||||
| 灵感 | `get_idea`(宏生成) | `src-tauri/src/commands/idea.rs` |
|
||||
|
||||
均按主键查,返回 `Option<Record>`/`Record`。这部分能力完整。
|
||||
|
||||
### 1.2 列表查询(本方案重点,维度薄弱)
|
||||
|
||||
| 实体 | 后端 WHERE 维度 | 前端筛选 | 分页 | 搜索 | 排序 |
|
||||
|------|----------------|---------|------|------|------|
|
||||
| 任务 | `project_id`(仅此 1 维,`task.rs:41`) | status 内存 filter | ❌ | ❌ | 固定 created_at DESC |
|
||||
| 项目 | 仅软删(`deleted_at`) | ❌ 无 UI | ❌ | ❌ | 固定 created_at DESC |
|
||||
| 灵感 | `status`(宏 `query`,单字段) | status+关键词+排序**全前端** | ❌ | ❌(后端) | 前端 computed |
|
||||
| 知识库 | status+kind+LIKE+向量 | — | ✅ limit | ✅ LIKE+向量 | ✅ 多维 |
|
||||
|
||||
**核心问题**:任务/项目/灵感的筛选全堆在**前端内存**(`Tasks.vue:177` `.filter()` / `Ideas.vue:184` computed),后端只给"按 project_id 拉"或"全量拉"。数据量增长后线性退化,且无法做关键词/多条件。
|
||||
|
||||
> 注:AI agent 用的 `list_tasks` 工具(`tool_registry.rs`)另有 status 内存过滤,但那是给 LLM 调的,非 UI 查询链路。
|
||||
|
||||
---
|
||||
|
||||
## 二、缺口清单
|
||||
|
||||
1. **status 未下沉后端**:任务/灵感的 status 过滤是前端 `.filter()`,后端 `list_tasks`/`list_ideas` 未支持 status 维度 SQL WHERE。
|
||||
2. **无关键词搜索**:三实体均无 title/description 文本检索。
|
||||
3. **无排序选项**:全固定 `created_at DESC`,无按优先级/状态/更新时间排序。
|
||||
4. **无分页**:全量返回,无 LIMIT/OFFSET。数据量大时撑爆前端内存 + IPC。
|
||||
5. **索引缺口**:tasks 缺 `idx_tasks_priority`/`idx_tasks_assignee`(已有 `idx_tasks_status`/`idx_tasks_project_id`,`migrations.rs:763-764`);projects/ideas 表无任何显式索引(仅主键)。
|
||||
|
||||
---
|
||||
|
||||
## 三、可复用基建(非从零造)
|
||||
|
||||
知识库模块(`KnowledgeRepo`)是唯一查询能力完善的实体,其模式可直接推广:
|
||||
|
||||
| 模式 | 位置 | 复用点 |
|
||||
|------|------|--------|
|
||||
| **动态 WHERE 拼接** | `crates/df-storage/src/crud/idea_repo.rs:200` `KnowledgeRepo::search` | if-let 分支按可选条件拼 SQL + 分支化参数绑定 |
|
||||
| **分页 + 上限钳制** | `crates/df-storage/src/crud/conversation_repo.rs:270` `list_recent` | `LIMIT ?1 OFFSET ?2` + `limit.min(200)` 防滥用 |
|
||||
| **统一查询入口** | `crates/df-storage/src/crud/mod.rs` `impl_repo!` 宏 `query(field,value)` | 单字段白名单动态查询,可扩展为多条件 |
|
||||
|
||||
SQL 库为 `rusqlite`,`?N` 占位符 + `params![]` 宏绑定参数。
|
||||
|
||||
---
|
||||
|
||||
## 四、设计方向
|
||||
|
||||
### 4.1 统一查询结构(对齐 [[no-patch-groundwork]],根本设计非补丁堆砌)
|
||||
|
||||
三实体各引入可选字段的 `XxxQuery` struct,而非逐个加 IPC 参数:
|
||||
|
||||
```rust
|
||||
// 示意:TaskQuery(项目/灵感同构)
|
||||
pub struct TaskQuery {
|
||||
pub project_id: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub priority: Option<i32>,
|
||||
pub assignee: Option<String>,
|
||||
pub keyword: Option<String>, // title/description LIKE %kw%
|
||||
pub order_by: Option<String>, // created_at/updated_at/priority/status
|
||||
pub limit: Option<u32>, // 钳制上限(对齐 list_recent)
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 repo 层:`list_by_query` 动态 WHERE
|
||||
|
||||
复用 `KnowledgeRepo::search` 模式:动态拼 `WHERE` 子句 + 参数绑定。`order_by` 走白名单(防 SQL 注入,对齐 `impl_repo!` 宏的 `validate_column_name`)。
|
||||
|
||||
### 4.3 命令层:`list_xxx` 吃 query,向后兼容
|
||||
|
||||
`list_tasks(query: Option<TaskQuery>)`。旧调用方不传(或传空 query)→ 等价全量(当前行为),零破坏。新调用方按需传筛选。
|
||||
|
||||
### 4.4 前端对接
|
||||
|
||||
- `api/{task,project,idea}.ts`:暴露 query 参数
|
||||
- `stores/project/*.ts`:`loadXxx(query)` 透传
|
||||
- 视图:现有前端内存 filter 改为构造 query 调后端;补 UI 控件(优先级筛选/搜索框;分页器待数据量大再加)
|
||||
|
||||
---
|
||||
|
||||
## 五、实施分阶段
|
||||
|
||||
| 阶段 | 维度 | 优先级 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| P1 | status 下沉后端 | 中 | 任务 + 灵感 status 过滤从前端 filter 改后端 WHERE(已有 `idx_tasks_status` 索引) |
|
||||
| P2 | 关键词搜索(LIKE) | 中 | title/description `LIKE %kw%`,对齐知识库。加搜索框 UI。不上 FTS5(需虚拟表 + 触发器同步,过度) |
|
||||
| P3 | 排序 + 分页 | 低 | 排序选项(优先级/状态/更新时间)+ limit 钳制防爆。UI 分页器待数据量 > 百级再加 |
|
||||
|
||||
> 数据量:当前 ~6 项目 ~15 任务,性能无感。P1/P2 是能力补全,P3 性能驱动——数据量上来才有必要,与 PERF-260619-01 性能方案协同。
|
||||
|
||||
---
|
||||
|
||||
## 六、关联文档
|
||||
|
||||
- [PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)——查询**性能**(全量加载/无缓存/无字段投影),与本方案(查询**维度**)正交,实施时协同改 `list_xxx`。
|
||||
- todo.md `F-15-03 分页`——本方案 P3 细化。
|
||||
- todo.md `BUG-260621-01`——run_workflow 空 dag 缺陷(本轮同批登记,与本查询方案独立)。
|
||||
394
docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md
Normal file
394
docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# F-260622-01 跨端 AI Chat Phase3 联调设计(协议统一 + 桥接方案)
|
||||
|
||||
> 创建:2026-06-22 | 状态:📐 设计草案(纯设计,未实施任何代码)
|
||||
> 上级索引:[../INDEX.md](../INDEX.md) | 续篇:[F-260620-01-跨端AIChat-微信小程序-2026-06-20.md](./F-260620-01-跨端AIChat-微信小程序-2026-06-20.md)
|
||||
|
||||
## 〇、定位与术语澄清(必读)
|
||||
|
||||
本文档是 **F-260620-01 的 Phase3 实施续篇**,专注「三层各自就绪后,如何把它们接线联调」的设计。
|
||||
|
||||
**术语对齐**(避免与 F-260620-01 已有阶段命名冲突):
|
||||
|
||||
| 术语 | 在 F-260620-01 的含义 | 在本文档的含义 |
|
||||
|---|---|---|
|
||||
| P1 / P2 / P3 / P4 | df-relay / df-tunnel / df-miniapp / 双向同步完善 | —(本文不重定义) |
|
||||
| Phase3 | —(未用) | **三层联调总称**:协议统一 + 事件桥接 + 指令路由 + 真机联调 |
|
||||
|
||||
**已就绪状态(核验结论,只读 grep 得出)**:
|
||||
|
||||
| 层 | 状态 | commit | 协议契约(当前形态) |
|
||||
|---|---|---|---|
|
||||
| df-relay | ✅ Phase2 | 2b8b30e | 透传不解析 payload(`serde_json::Value`),按 `kind` 路向:Event(Device→Miniapp)/ Command(Miniapp→Device)/ Control |
|
||||
| df-tunnel | ✅ Phase2 | 25d6565 | 收 `TunnelCommand{kind,...}`(`#[serde(tag="kind")]` 5 变体:Send/Stop/Approve/Regenerate/Switch);发 `TunnelEvent`(4 变体子集) |
|
||||
| df-miniapp | ✅ | 280baea | 发 `MiniCommand{cmd, args}`(`cmd` 对齐 Tauri command 名,如 `"send_message"`;`args.message`/`args.conversation_id`) |
|
||||
|
||||
**核心阻断问题**:relay 透传不转换,miniapp 发 `MiniCommand`,tunnel 收 `TunnelCommand` —— **两端序列化结构不匹配**,联调在协议层即阻断。本文第一节即决策如何统一。
|
||||
|
||||
---
|
||||
|
||||
## 一、协议统一决策(A / B,论证给推荐)
|
||||
|
||||
### 1.1 不匹配点逐项对照(源码核验)
|
||||
|
||||
miniapp 端 `apps/df-miniapp/src/types/relay.ts:88-93` + `useAiChat.ts:379-385`:
|
||||
```ts
|
||||
interface MiniCommand { cmd: string; args: Record<string, unknown> }
|
||||
// 实际发送:{ cmd: "send_message", args: { message, conversation_id } }
|
||||
```
|
||||
|
||||
tunnel 端 `crates/df-tunnel/src/events.rs:119-132`:
|
||||
```rust
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum TunnelCommand {
|
||||
Send { conv_id: String, content: String },
|
||||
Stop { conv_id: String },
|
||||
Approve { conv_id: String, approval_id: String, accepted: bool },
|
||||
Regenerate { conv_id: String },
|
||||
Switch { conv_id: String },
|
||||
}
|
||||
```
|
||||
|
||||
**三重不匹配**:
|
||||
|
||||
| 维度 | miniapp(发) | tunnel(收) | 不匹配 |
|
||||
|---|---|---|---|
|
||||
| 外层结构 | `{cmd, args}` 两字段 | `{kind, ...}` 内联(tag enum) | 结构不匹配 |
|
||||
| 标签词 | `cmd:"send_message"` | `kind:"send"` | 词汇不匹配(send_message vs send) |
|
||||
| 字段名 | `args.message` / `args.conversation_id` | `content` / `conv_id` | 字段名不匹配 |
|
||||
| 审批/重试 | 未发(cmd 仅 3 条 MVP) | Approve/Regenerate 已定义 | 覆盖面不匹配 |
|
||||
|
||||
tunnel 的 `parse_command_from_broadcast`(`tunnel.rs:439-455`)对 payload 做 `serde_json::from_value::<TunnelCommand>` —— miniapp 的 `{cmd, args}` 反序列化为 TunnelCommand 必然失败(无 `kind` 字段、`cmd` 值 `"send_message"` 不在 5 变体内)。**联调第一步就被协议层挡死。**
|
||||
|
||||
### 1.2 方案 A:tunnel 改纯透传 + device 端 src-tauri 桥接层解协议
|
||||
|
||||
**做法**:
|
||||
- df-tunnel 不再定义强类型 `TunnelCommand`,入站回调改为 `on_raw_command(payload: serde_json::Value)`(或保留 TunnelCommand 但加一条「payload 原样上抛」的兜底分支)。
|
||||
- tunnel 退回纯传输层,只认「这是 Miniapp→Device 的 Command 方向」,不解析 payload 语义。
|
||||
- 真正的协议解析在 **device 端 src-tauri 新增的桥接层**:收到 `Value` 后,按 miniapp 约定的 `{cmd, args}` 反序列化,`match cmd { "send_message" => ai_chat_send(...), "stop" => ai_chat_stop(...), ... }` 路由到现有 Tauri command。
|
||||
|
||||
**论证**:
|
||||
|
||||
| 维度 | 评价 |
|
||||
|---|---|
|
||||
| 架构原则(no-patch-groundwork) | ✅ **强对齐**。relay/tunnel 是传输层,职责是「把字节从 A 搬到 B」,业务协议(命令名、字段名)属于业务层。让传输层认 `kind:"send"` 是越界耦合 —— tunnel crate 不该知道「send 是什么意思」。把协议知识下沉到 tunnel 等于把业务逻辑泄露进传输层,违反单一职责。 |
|
||||
| 改动面 | tunnel:删/弱化 `TunnelCommand` 强类型解析,改 `on_command` 签名(~30 行);miniapp:**零改动**(继续发 `MiniCommand`);src-tauri:新增桥接模块(~150 行,纯新增)。 |
|
||||
| device 桥接复杂度 | 中。桥接层是「`MiniCommand` → `Tauri command 调用`」的翻译表,本质是 match 表,新增命令只加一行 match 臂。 |
|
||||
| 扩展性 | ✅ **强**。miniapp 新增命令只动 miniapp + 桥接层 match 表,tunnel/relay 永不需要改。符合「两端同语言、中间无业务」的 F-260620-01 技术选型初衷(Rust 优势在两端类型一致,中间透传)。 |
|
||||
| 向后兼容 | ✅ tunnel 不再定义命令类型,未来协议字段变更不波及 tunnel。 |
|
||||
| 与现有 Tauri 命令对齐 | ✅ **天然对齐**。`MiniCommand.cmd` 直接复用 Tauri command 函数名(`ai_chat_send` 等),桥接层几乎是 1:1 invoke。miniapp 已按此设计(relay.ts:82 注释「对齐 Tauri command 名」)。 |
|
||||
|
||||
### 1.3 方案 B:miniapp 改发 TunnelCommand 格式
|
||||
|
||||
**做法**:
|
||||
- miniapp 端把 `MiniCommand{cmd, args}` 改成 `TunnelCommand{kind, ...}`,字段名对齐 tunnel(`conv_id`/`content`)。
|
||||
- tunnel 端零改动,继续强类型解析。
|
||||
|
||||
**论证**:
|
||||
|
||||
| 维度 | 评价 |
|
||||
|---|---|
|
||||
| 架构原则 | ⚠️ **偏离**。等于承认「tunnel 定义的业务协议就是跨端协议」,传输层握有协议定义权。但 tunnel crate 文档明确写「不依赖 src-tauri/df-types(避跨 crate 强耦合)」(events.rs:5-7)—— 它的 TunnelCommand 是**独立镜像**,不是权威契约。让 miniapp 对齐一个非权威的镜像契约,契约源头混乱。 |
|
||||
| 改动面 | miniapp:`relay.ts` + `useAiChat.ts` + 可能 `events.ts` 全改(~80 行);tunnel:零改动;src-tauri:仍需桥接层(把 `TunnelCommand::Send{conv_id, content}` 翻译成 `ai_chat_send(message=content, conversation_id=conv_id)`)—— **桥接层省不掉**,只是输入类型变了。 |
|
||||
| device 桥接复杂度 | 中。仍是 match 翻译,但要做 `conv_id↔conversation_id`、`content↔message` 字段名映射(tunnel 用简写 conv_id,src-tauri 用全称 conversation_id)。 |
|
||||
| 扩展性 | ⚠️ **弱**。每加一条命令要同时改 tunnel 的 TunnelCommand 枚举 + miniapp + 桥接层三处,tunnel 被迫随业务膨胀,违背它「轻量传输」定位。 |
|
||||
| 向后兼容 | ⚠️ tunnel TunnelCommand 一旦发布就是协议的一部分,后续字段重命名(conversation_id 全称统一?)会破坏 miniapp。 |
|
||||
| 与 Tauri 命令对齐 | ❌ **不对齐**。`kind:"send"` ≠ Tauri `ai_chat_send`,`conv_id` ≠ `conversation_id`。桥接层要做双重翻译(协议词 + 字段名)。 |
|
||||
|
||||
### 1.4 推荐:方案 A
|
||||
|
||||
**理由(按权重排序)**:
|
||||
|
||||
1. **no-patch-groundwork 元原则(最高权重)**:relay/tunnel 是传输层,纯透传是它的天职。当前 tunnel 强类型 `TunnelCommand` 是 Phase2 实现「先跑通握手」时的便利设计,但它在架构上是**业务知识下沉到传输层**的技术债。Phase3 联调正是清这笔债的时机 —— 要么现在清,要么以后协议每次扩张都拖 tunnel 一起改,债越滚越大。
|
||||
2. **契约源头唯一**:权威业务协议在 src-tauri(Tauri command 即事实标准),miniapp 已主动对齐(relay.ts:82)。方案 A 让 tunnel 也对齐这个源头(透传即对齐「 whatever 两端约定」),三方契约源头唯一。方案 B 会造出第二个源头(tunnel 的 TunnelCommand)。
|
||||
3. **改动面更小且更安全**:方案 A 的 tunnel 改动是「弱化解析」(删类型比加类型安全,删错最多漏一条命令,加错会阻断全部),miniapp 零改动,src-tauri 新增纯加法。方案 B 要改 miniapp 三个文件,回归风险更高。
|
||||
4. **桥接层省不掉,方案 A 让它更简单**:无论 A/B,device 端都要把「线上协议」翻译成「Tauri command 调用」。方案 A 的输入是 `{cmd, args}`,cmd 已是 Tauri 函数名,翻译近乎恒等;方案 B 还要额外做 `send↔ai_chat_send`、`conv_id↔conversation_id` 词表映射。
|
||||
|
||||
**风险与缓解**:
|
||||
- 风险:方案 A 后 tunnel 失去「编译期校验命令合法性」的能力,非法命令要到运行时桥接层才报错。
|
||||
- 缓解:桥接层 match 末尾加兜底臂 `_ => log+忽略`,非法命令不崩溃只记录;miniapp 端 TS 类型(`MiniCommand`)仍提供编译期校验(前端侧),tunnel 侧的运行时校验丢失可接受(它本就不该是校验点)。
|
||||
|
||||
> **决策点(留待主代理/用户定)**:本节给推荐 A,但 A/B 均合理。若团队更看重「tunnel 零改动、协议在 miniapp 侧收敛」可定 B。一旦定 A,tunnel 的 TunnelCommand 是否彻底删除 vs 保留作可选强类型校验层(对常见命令做 best-effort 解析,失败回落透传)是次级决策,建议保留作弱校验(向后兼容更好)。
|
||||
|
||||
---
|
||||
|
||||
## 二、src-tauri AiSession 桥接设计(方案 A 假定下)
|
||||
|
||||
桥接分两个方向:**Event 上行透传**(device→miniapp)、**Command 下行路由**(miniapp→device)。
|
||||
|
||||
### 2.1 Event 上行:把 AiChatEvent 经 tunnel 推给 miniapp
|
||||
|
||||
#### 现状核验
|
||||
|
||||
- AiChatEvent 19 变体定义于 `src-tauri/src/commands/ai/mod.rs:104-216`(变体清单见附录 A)。
|
||||
- 事件经 `app_handle.emit("ai-chat-event", AiChatEvent::...)` **全局广播**,事件名硬编码散布在 **7 个文件 55 处**(event_bus.rs / agentic/mod.rs / agentic/guard.rs / mod.rs / commands/chat.rs / stream_recv.rs / audit/mod.rs)。
|
||||
- 前端(`src/api/ai.ts:258`)通过 `listen<AiChatEvent>('ai-chat-event', ...)` 单一事件名订阅,按 `event.type` discriminator 分派。
|
||||
- 事件携带 `conversation_id: Option<String>` 字段做多会话路由。
|
||||
|
||||
#### 桥接注入点选择(关键设计)
|
||||
|
||||
**问题**:55 处 emit 散布各处,逐处加 tunnel 推送 = 55 处改动 + 必然遗漏。需要单一汇聚点。
|
||||
|
||||
**选项对比**:
|
||||
|
||||
| 注入点 | 做法 | 评价 |
|
||||
|---|---|---|
|
||||
| (a) 逐 emit 点 clone | 在每处 `app.emit` 旁加 `tunnel.send_event(...)` | ❌ 55 处改动,违反 DRY,遗漏必然 |
|
||||
| (b) Tauri 事件拦截 | 注册一个全局 `on_event` 监听 `ai-chat-event`,收到后转发 tunnel | ⚠️ 在 src-tauri 内部 listen 自己 emit 的事件,绕一圈(后端→前端事件总线→后端),不优雅,且 Tauri 的 emit 是给前端的,后端监听要 `app.listen` API |
|
||||
| (c) **EventBus 汇聚(推荐)** | 接入现有 `event_bus.rs` 骨架(已建未接),把 55 处 emit 改为 `event_bus.publish(...)` 一处汇聚,EventBus 内部同时做 `app.emit` + `tunnel.send_event` | ✅ **单一汇聚点**,tunnel 作为一个 subscriber 接入,符合 EventBus 设计初衷(event_bus.rs:6-7 注释明确「散布各处无统一总线」就是为汇聚而建) |
|
||||
| (d) 新建 tunnel gate 函数 | 包装 `emit_ai_event(app, tunnel, event)`,55 处改调它 | ✅ 也汇聚,但与 EventBus 重复造轮子;EventBus 已是「为这件事」建的基础设施 |
|
||||
|
||||
**推荐 (c)**:接入 EventBus。理由:
|
||||
1. EventBus 是**专门为此场景建的基础设施**(event_bus.rs 文档自述),当前是骨架「批2接入 emit 点」未做 —— Phase3 桥接正好是它第一批真实 subscriber。
|
||||
2. tunnel 作为 EventBus 的一个 subscriber(`EventBus::subscribe` 返回 `EventSubscriber`),EventBus `publish` 时 fan-out 到所有 subscriber。tunnel 的 subscriber 收到事件后 `send_event(TunnelEvent::...)`。
|
||||
3. 与「全局事件数据总线」专项设计文档(docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md)阶段 6「跨端透传 df-tunnel adapter」完全对齐 —— 不是新造,是落地该规划的子集。
|
||||
4. `EVENT_BUS_ENABLED` 开关(event_bus.rs:42)天然提供回退:桥接出问题关开关,原 emit 路径不受影响(event_bus.rs:45 注释明示此兜底语义)。
|
||||
|
||||
#### 哪些事件变体需要透传(子集决策)
|
||||
|
||||
miniapp MVP 已实现 `handleEvent`(useAiChat.ts:100-314)处理 18 个变体中的多数。但 tunnel 端 `TunnelEvent`(events.rs:63-101)只定义了 **4 个变体**(TextDelta/ToolCall/Approval/Completed)。方案 A 下 miniapp 收的是「relay 透传的 AiChatEvent 原样 JSON」,不依赖 tunnel 的 TunnelEvent 强类型 —— **所以 tunnel 也应纯透传 AiChatEvent payload,不再裁剪到 4 变体**。
|
||||
|
||||
**推荐**:全 19 变体透传(不裁剪),理由:
|
||||
- miniapp handleEvent 已能处理绝大多数变体,裁剪反而让某些事件(如 AiCompressed/AiHelpRequired)在 miniapp 永远收不到。
|
||||
- 全透传 = tunnel 零业务知识(对齐方案 A 原则)。
|
||||
- 带宽:19 变体大多是低频(AiCompleted/AiError 每轮一次),只有 AiTextDelta 高频但单条小。MVP 阶段全透传带宽无忧。
|
||||
|
||||
**实现要点(设计,非代码)**:
|
||||
- tunnel `send_event` 入参类型从 `TunnelEvent` 放宽为 `serde_json::Value`(或新增 `send_raw_event(Value)`)。
|
||||
- EventBus subscriber 在 src-tauri 桥接层:把 AiChatEvent 序列化为 Value → `tunnel.send_raw_event(value)` → tunnel 写 socket → relay 透传 → miniapp `onEvent(msg)` 里 `msg.payload as AiChatEvent` 直接用(useAiChat.ts:333 已是此假设)。
|
||||
- TunnelEvent 强类型 4 变体(events.rs:63-101)**保留作可选**,不删除 —— 它可作为「高频路径的强类型快捷方式」或测试断言用,删它是方案 A 的过度演绎(对齐 dead-code-reserve-keep 原则:零调用方的预留保留)。
|
||||
|
||||
#### AiChatEvent 全局广播 vs 路由
|
||||
|
||||
当前 emit 是全局广播(无 conv 过滤)。miniapp 单会话视图只关心 `activeConversationId` 的事件(useAiChat.ts:109 按 `isCurrent` 过滤)。透传策略:
|
||||
- **透传全部,miniapp 侧过滤**(推荐):tunnel 不做 conv 过滤,miniapp 收到后按 `event.conversation_id` 自行过滤。简单、对齐现状、不丢事件。
|
||||
- tunnel 侧按 conv 过滤:需 tunnel 知道「miniapp 当前看哪个 conv」—— 引入反向状态同步,复杂度高,MVP 不做。
|
||||
|
||||
### 2.2 Command 下行:收到 MiniCommand 路由到 Tauri command
|
||||
|
||||
#### 桥接层设计
|
||||
|
||||
device 端 src-tauri 新增桥接模块(设计上,如 `commands/ai/remote_bridge.rs` 或挂在 tunnel 初始化处),注册为 tunnel 的 `on_command` 回调(方案 A 下回调签名收 `serde_json::Value`)。
|
||||
|
||||
路由表(MiniCommand.cmd → Tauri command,基于 src-tauri 现有命令清单核验,附录 B):
|
||||
|
||||
| MiniCommand.cmd | args 字段 | 调用的 Tauri command | 命令签名(附录 B 摘要) |
|
||||
|---|---|---|---|
|
||||
| `"send_message"` | `{message, conversation_id, model_override?}` | `ai_chat_send` | `(app, state, message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans?)` |
|
||||
| `"stop"` | `{conversation_id}` | `ai_chat_stop` | `(state, app, conversation_id?)` |
|
||||
| `"switch_conversation"` | `{conversation_id}` | (无直接命令,见下) | — |
|
||||
| `"regenerate"` | `{conversation_id}` | `ai_regenerate` | `(app, state, conversation_id, language?, model_override?)` |
|
||||
| `"approve"` | `{tool_call_id, approved}` | `ai_approve` | `(app, state, tool_call_id, approved)` |
|
||||
| `"authorize_dir"` | `{tool_call_id, decision}` | `ai_authorize_dir` | `(app, state, tool_call_id, decision)` |
|
||||
| `"continue_loop"` | `{conversation_id}` | `ai_continue_loop` | `(app, state, conversation_id)` |
|
||||
| `"stop_loop"` | `{conversation_id}` | `ai_stop_loop` | `(app, state, conversation_id)` |
|
||||
|
||||
#### 关键缺口:switch_conversation 无对应 Tauri command
|
||||
|
||||
核验发现:src-tauri **没有** `ai_switch_conversation` 命令。miniapp(useAiChat.ts:439)发 `switch_conversation` 但桌面端无对应入口。
|
||||
|
||||
**原因分析**:桌面端的「切换会话」是**纯前端操作**(改 `activeConversationId` 状态 + 从本地 store 加载消息历史),不经 Tauri command —— 因为真相源在桌面本地,切会话只是视图切换。
|
||||
|
||||
**跨端影响**:miniapp 切会话时,桌面端若不同步,两边 `activeConversationId` 不一致;但 AI 事件透传是全局广播(附 conv_id),miniapp 按 conv_id 自过滤,功能上不阻断。差异只在「桌面端 UI 高亮哪条会话」。
|
||||
|
||||
**设计建议(留待主代理定)**:
|
||||
- (a) **不处理**:miniapp switch 仅本地视图切换,不通知桌面。桌面 UI 与 miniapp 各自维护 active。简单,但两端 active 可能不一致。
|
||||
- (b) **加一个轻量 sync command**:新增 `ai_sync_active_conversation(app, state, conversation_id)`,仅更新 `AiSession.active_conversation_id`(mod.rs:350),不触发其他逻辑。供桌面 UI 监听同步高亮。
|
||||
- 推荐 (a) 起步(MVP 不阻断),(b) 列入 P4 双向同步完善(F-260620-01 已留 P4 阶段)。
|
||||
|
||||
#### on_command 回调签名适配(方案 A)
|
||||
|
||||
tunnel 当前 `CommandHandler = Arc<dyn Fn(TunnelCommand) -> BoxFuture<()>>`(tunnel.rs:42)。方案 A 下需放宽为收 `serde_json::Value`:
|
||||
|
||||
```
|
||||
CommandHandler = Arc<dyn Fn(serde_json::Value) -> BoxFuture<()>>
|
||||
```
|
||||
|
||||
桥接层回调内部:
|
||||
1. 尝试反序列化 `MiniCommand{cmd, args}`(失败 → log+忽略,可能 miniapp 发了未知格式)。
|
||||
2. `match cmd { ... }` 路由到对应 Tauri command 函数(直接 async 调用,因为桥接层在 src-tauri 内,有 app+state 句柄)。
|
||||
3. command 返回值忽略(结果经 AiChatEvent 回流,不走 command 返回值)。
|
||||
|
||||
**注意**:Tauri command 函数签名第一参数是 `AppHandle` / `State<AppState>`,桥接层需在初始化时持有这俩句柄(从 tunnel 启动处 clone 进闭包)。这是标准 Tauri 模式,无新风险。
|
||||
|
||||
---
|
||||
|
||||
## 三、AiSession 单例并发模型桥接风险(重点)
|
||||
|
||||
桥接最大风险不在协议,而在 **miniapp 远程指令与桌面本地操作并发撞击 AiSession 单例状态**。核验结论(mod.rs:348-383):
|
||||
|
||||
### 3.1 现状:F-09 决策 e 已落地真多会话并发
|
||||
|
||||
- `AiSession.per_conv: HashMap<String, PerConvState>`(mod.rs:364)是唯一真相源,顶层单例字段(messages/generating/stop_flag)已删除。
|
||||
- 每个 conv 独立 `generating: bool` + `stop_flag: Arc<AtomicBool>` + `notify: Arc<Notify>`(PerConvState,mod.rs:632-716)。
|
||||
- `pending_approvals: HashMap<String, PendingApproval>`(mod.rs:358)单表,带 `conversation_id` 字段路由。
|
||||
- **真支持多会话并行生成**(commit 126bee5 「F-09生成中新建对话不中断」已去中断弹窗 + 死 key 清理)。
|
||||
|
||||
**这是跨端桥接的利好**:miniapp 操作 conv-X 时,桌面用户可在 conv-Y 继续操作,互不中断。conv_id 路由天然隔离。
|
||||
|
||||
### 3.2 风险点清单
|
||||
|
||||
| # | 风险 | 场景 | 影响 | 缓解 |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **同 conv 并发发送** | miniapp 与桌面同时给 conv-X 发消息 | ai_chat_send 内部若未做「generating 中拒绝」校验,可能触发两路 agent loop 抢同一 PerConvState | 核验:ai_chat_send 应有 generating guard(对照 desktop useAiSend 发送前检查)。桥接层额外加 `if conv_read(conv).generating { 拒绝+回错事件 }` 兜底。 |
|
||||
| R2 | **审批双端竞态** | miniapp 与桌面同时批/拒同一 tool_call_id | ai_approve 两次调用,pending_approvals 第二次取不到 → 返回错;但 try_continue_agent_loop 可能被触发两次 | 核验 ai_approve 是否幂等(取走即删);桥接层不额外加锁(依赖 command 内部 HashMap 操作的原子性,Tauri command 在 state 锁内串行)。低风险。 |
|
||||
| R3 | **审批阻塞 + 远程指令堆积** | conv-X 卡在 AwaitingApproval,miniapp 持续发 send/stop | stop 命令对 AwaitingApproval 状态的语义不清(停止生成 vs 停止等待审批?) | 桥接层对 stop 命令加状态判断:`if session_state(conv) == AwaitingApproval { 走 ai_stop_loop 或提示「等待审批中」} `。需核验 ai_chat_stop 对审批态的行为。 |
|
||||
| R4 | **generating 状态机复位散布** | (已知技术债,见 memory devflow-generating-statemachine)generating 复位逻辑散布,远程 stop 若走到非常规路径可能不复位 | 卡在 generating=true 假死 | 桥接层 stop 走标准 ai_chat_stop command(复用现有复位逻辑),不开新路径。状态机加固是独立工作项,不在 Phase3 范围。 |
|
||||
| R5 | **AiSession 单例锁竞争** | 远程指令高频 + 桌面本地高频 → 抢 state Mutex | 锁等待,延迟增高(非死锁) | AiSession 用 tokio Mutex,临界区小。MVP 无忧,P4 高并发再优化。 |
|
||||
| R6 | **事件风暴压垮 miniapp** | 桌面多 conv 并行生成,miniapp 收到大量非当前 conv 事件 | miniapp 端过滤(useAiChat.ts:109)但 WS 带宽与解析压力 | tunnel 侧可选 conv 过滤(需反向状态同步,见 2.1,P4 做);MVP 全透传 + miniapp 过滤。 |
|
||||
| R7 | **WS 断连期间远程操作丢失** | miniapp 发 send 时 WS 已断(uni-app send 静默失败) | useAiChat.ts:386 已有发送失败回错,但用户可能误以为发出 | miniapp wsStatus 已暴露,UI 应在 disconnected 时禁用发送按钮(前端职责)。 |
|
||||
| R8 | **桌面端离线,relay 丢弃指令** | relay route 无对端在线时丢弃(relay.rs:135-143 delivered=0 返回 Ok) | miniapp 发的命令静默消失 | miniapp 侧应先查 device 在线状态(relay 可加 presence 查询,P4);MVP 提示「桌面端未连接」。 |
|
||||
|
||||
### 3.3 最高优先级风险:R1(同 conv 并发发送)
|
||||
|
||||
这是唯一可能造成**状态损坏**(非仅体验问题)的风险。需在实施前**先核验** `ai_chat_send` 内部是否有 generating guard:
|
||||
|
||||
- 若有(返回 Err 如「正在生成中」):桥接层把 Err 转成 `AiError` 事件 emit 回 miniapp,安全。
|
||||
- 若无:桥接层必须在调 ai_chat_send 前自行检查 `conv_read(conv).generating`,true 则拒绝。**这是桥接层的硬性兜底,不可省**。
|
||||
|
||||
> 设计层面定:桥接层 send 路由 **必须**前置 generating 检查,无论 ai_chat_send 内部是否有 guard(防御性编程,双保险)。其他命令(stop/approve)无需此检查。
|
||||
|
||||
---
|
||||
|
||||
## 四、分阶段实施路线(每阶段可独立验证可回退)
|
||||
|
||||
按「协议统一 → 单向桥接 → 双向桥接 → 真机联调」四阶段递进,每阶段产出可独立验证、出问题可单独回退。
|
||||
|
||||
### 阶段 1:协议统一(tunnel 改纯透传)
|
||||
|
||||
**目标**:消除 1.1 的三重不匹配,tunnel 与 miniapp 在协议层握手成功。
|
||||
|
||||
**改动**:
|
||||
- df-tunnel:`CommandHandler` 签名 `Fn(TunnelCommand)` → `Fn(serde_json::Value)`(events.rs/tunnel.rs);`handle_inbound` 直接把 payload Value 上抛回调,不再 `from_value::<TunnelCommand>`(tunnel.rs:407-454);`TunnelEvent` 保留但新增 `send_raw_event(Value)` 伴行。
|
||||
- df-miniapp:**零改动**(继续发 MiniCommand)。
|
||||
- src-tauri:本阶段不涉及。
|
||||
|
||||
**验证**:单元测试 —— tunnel 收到 relay 转发的 `{kind:"command", payload:{cmd:"send_message", args:{...}}}` 后,回调收到 `Value` 正是 payload 内容;`parse_command_from_broadcast` 改为返回 Value 而非 TunnelCommand。
|
||||
|
||||
**回退**:tunnel git revert 单 commit 即恢复强类型 TunnelCommand。miniapp 未动,无回退负担。
|
||||
|
||||
**风险**:低。纯类型放宽,不增业务逻辑。
|
||||
|
||||
### 阶段 2:单向桥接 — Event 上行透传(device→miniapp)
|
||||
|
||||
**目标**:桌面端 AI 生成事件能流到 miniapp,miniapp 能渲染流式回复。此阶段 **Command 方向不通**(miniapp 发的命令无人处理)。
|
||||
|
||||
**改动**:
|
||||
- src-tauri:接入 EventBus(把 55 处 `app.emit("ai-chat-event", e)` 改为 `event_bus.publish(...)` 一处;EventBus 内部 fan-out:`app.emit` + `tunnel.send_raw_event(value)`)。tunnel 作为 subscriber 注册。
|
||||
- df-tunnel:零改动(阶段 1 已支持 send_raw_event)。
|
||||
- df-miniapp:零改动(useAiChat.ts:333 已按 `msg.payload as AiChatEvent` 处理)。
|
||||
|
||||
**依赖**:EventBus 当前是骨架(event_bus.rs:53 标 allow dead_code),需先做「批2 接入 emit 点」(其文档自述的下一步)。这是 EventBus 自身的里程碑,Phase3 阶段 2 与之合并。
|
||||
|
||||
**验证**:桌面端发一条 AI 消息,miniapp 收到流式 TextDelta 并渲染。**端到端单向通路打通**。
|
||||
|
||||
**回退**:`EVENT_BUS_ENABLED=false`(event_bus.rs:42),EventBus 静默丢事件但原 `app.emit` 路径不受影响(event_bus.rs:45 兜底语义)。**桌面前端不回归**。tunnel subscriber 失败不影响 EventBus 对前端的 emit。
|
||||
|
||||
**风险**:
|
||||
- EventBus 接入 55 处 emit 是大改(纯机械替换但有遗漏风险)→ 用 grep 全量替换 + 编译校验 + 逐文件 review。
|
||||
- 若 EventBus 有 bug,publish 可能阻塞 emit 路径 → EventBus.publish 应是非阻塞 fan-out(订阅方 send 失败不阻断其他订阅)。
|
||||
|
||||
### 阶段 3:双向桥接 — Command 下行路由(miniapp→device)
|
||||
|
||||
**目标**:miniapp 发 send/stop/approve 等命令,桌面端执行,全双工闭环。
|
||||
|
||||
**改动**:
|
||||
- src-tauri:新增桥接模块,注册为 tunnel `on_command` 回调(收 Value → 解 MiniCommand → match 路由到 Tauri command)。持 AppHandle + State 句柄。**含 R1 兜底**:send 前置 generating 检查。
|
||||
- df-tunnel:零改动(阶段 1 已支持 Value 回调)。
|
||||
- df-miniapp:补齐命令发送(approve/authorize_dir/continue_loop/stop_loop 的 MiniCommand 构造,useAiChat.ts 当前只发 send/stop/switch/regenerate 4 条)。
|
||||
|
||||
**验证**:miniapp 发 send → 桌面触发 ai_chat_send → AI 回复事件经阶段 2 通路回流 miniapp 渲染。**全双工闭环**。
|
||||
|
||||
**回退**:桥接模块独立,tunnel `on_command` 回调改为 no-op(空闭包)即切断下行,阶段 2 的上行通路不受影响。
|
||||
|
||||
**风险**:
|
||||
- R1(同 conv 并发)→ 阶段 3 必须实现 generating 前置检查。
|
||||
- R3(审批阻塞 + 远程指令)→ stop 命令对 AwaitingApproval 状态行为需先核验 ai_chat_stop。
|
||||
- 桥接层调 Tauri command 是直接 async fn 调用(非 IPC),需确保 AppHandle/State 句柄在桥接闭包内有效(从 tunnel 初始化处 clone,标准模式)。
|
||||
|
||||
### 阶段 4:真机联调 + 真多会话并发验证
|
||||
|
||||
**目标**:微信开发者工具 + 真机 + 桌面端三方联调,验证 F-09 多会话并发在跨端场景的鲁棒性。
|
||||
|
||||
**验证矩阵**:
|
||||
| 场景 | 预期 |
|
||||
|---|---|
|
||||
| miniapp 单会话收发 | 全双工正常 |
|
||||
| 桌面 + miniapp 同 conv 同时发 | 一方被 R1 兜底拒绝,另一方成功 |
|
||||
| 桌面 conv-X + miniapp conv-Y 并行 | 互不中断,事件按 conv_id 路由 |
|
||||
| miniapp 发 send 后桌面立即关 tunnel | miniapp 收到 incomplete 事件或断连提示 |
|
||||
| miniapp 审批 + 桌面同时审批同 tool_call | 一方成功一方收到「已处理」(R2) |
|
||||
| WS 断线重连(uni-app onShow 触发) | 事件不丢(重连后 AiConvStateChanged 同步状态) |
|
||||
|
||||
**回退**:每阶段独立,阶段 4 出问题回退到阶段 3 的 mock 联调环境。
|
||||
|
||||
**风险**:
|
||||
- 真机 WS 稳定性(微信小程序 WS 限制:单连接、超时回收、后台冻结)→ 心跳间隔(25s,tunnel.rs:87)+ onShow 重连(ws.ts:82)已覆盖大部分。
|
||||
- R6 事件风暴(多 conv 并行)→ 真机若卡顿,阶段 4 后期考虑 tunnel 侧 conv 过滤。
|
||||
|
||||
---
|
||||
|
||||
## 五、总结:决策点与待办映射
|
||||
|
||||
### 设计决策点(本文给推荐,留主代理/用户定)
|
||||
|
||||
| # | 决策 | 推荐 | 备选 | 依据 |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 协议统一方案 | **A**(tunnel 纯透传 + src-tauri 桥接解协议) | B(miniapp 改发 TunnelCommand) | §1.4 no-patch-groundwork + 契约源头唯一 |
|
||||
| D2 | Event 透传子集 | **全 19 变体透传** | 裁剪高频子集 | §2.1 方案 A 下 tunnel 零业务知识 |
|
||||
| D3 | Event 注入点 | **EventBus 汇聚**(subscriber 接入) | 逐 emit 点 clone / 新建 gate 函数 | §2.1 EventBus 即为此建 |
|
||||
| D4 | switch_conversation 处理 | **(a) 不处理**(MVP) | (b) 新增 sync command | §2.2 列入 P4 |
|
||||
| D5 | TunnelCommand/TunnelEvent 强类型 | **保留**(标 allow,作弱校验/测试) | 删除 | dead-code-reserve-keep 原则 |
|
||||
| D6 | R1 兜底前置位置 | **桥接层 send 路由硬性加** | 依赖 ai_chat_send 内部 guard | §3.3 双保险 |
|
||||
|
||||
### 待办登记(供主代理入 todo)
|
||||
|
||||
> 以下为实施项,本文只设计不实施。
|
||||
|
||||
- [ ] F-260622-01-阶段1:tunnel CommandHandler 签名放宽为 Value + parse_command_from_broadcast 改返回 Value
|
||||
- [ ] F-260622-01-阶段2:EventBus 接入 55 处 emit 点(批2)+ tunnel 注册为 subscriber + send_raw_event
|
||||
- [ ] F-260622-01-阶段3:src-tauri 桥接模块(MiniCommand→Tauri command 路由 + R1 兜底)+ miniapp 补齐 approve/authorize_dir/continue/stop_loop 命令
|
||||
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵
|
||||
- [ ] 核验项:ai_chat_send 内部 generating guard 现状(决定 R1 兜底是否冗余)
|
||||
- [ ] 核验项:ai_chat_stop 对 AwaitingApproval 状态行为(R3)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:AiChatEvent 19 变体清单(核验自 mod.rs:104-216)
|
||||
|
||||
AiTextDelta / AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult / AiCompleted / AiError / AiAgentRound / AiHeartbeat / AiMaxRoundsReached / AiStreamRetry / AiDirAuthRequired / AiContextCleared / AiCompressing / AiCompressed / AiHelpRequired / AiConvStateChanged
|
||||
|
||||
(注:18 变体 + ConvState 派生,文档统称 18-19,F-260620-01 称「17 变体」是早期计数,以源码核验为准。)
|
||||
|
||||
miniapp handleEvent(useAiChat.ts:112-313)已覆盖其中 16 个,未覆盖:AiToolAutoApproved(仅 console)、AiDirAuthRequired(仅提示)、AiContextCleared/AiCompressing(no-op)—— 均属合理简化。
|
||||
|
||||
## 附录 B:src-tauri AI Chat 相关 Tauri command 清单(核验自 commands/chat.rs)
|
||||
|
||||
| 命令 | 关键参数 |
|
||||
|---|---|
|
||||
| `ai_chat_send` | message, language?, skill?, model_override?, conversation_id?, parts?, mention_spans? |
|
||||
| `ai_chat_force_send` | (同 send,强制绕过 generating 检查) |
|
||||
| `ai_chat_stop` | conversation_id? |
|
||||
| `ai_regenerate` | conversation_id, language?, model_override? |
|
||||
| `ai_chat_edit` | conversation_id, new_message, language?, model_override? |
|
||||
| `ai_approve` | tool_call_id, approved |
|
||||
| `ai_authorize_dir` | tool_call_id, decision("once"/"always"/"deny") |
|
||||
| `ai_continue_loop` | conversation_id |
|
||||
| `ai_stop_loop` | conversation_id |
|
||||
| `ai_is_generating` | conversation_id? → bool |
|
||||
| `ai_pending_tool_calls` | conv_id → Vec<PendingToolCallInfo> |
|
||||
| `ai_chat_clear` / `ai_chat_clear_context` / `ai_chat_compress_context` | (上下文管理类) |
|
||||
|
||||
注:`ai_chat_force_send` 可作为 R1 兜底的备选(若桥接层确需在 generating 中强发,改调 force_send),但默认不用于远程(避免误并发)。
|
||||
|
||||
## 附录 C:核验证据索引(源码行号)
|
||||
|
||||
| 结论 | 证据 |
|
||||
|---|---|
|
||||
| relay 透传不解析 | `crates/df-relay/src/broadcast.rs:58-59`(payload: serde_json::Value)+ `relay.rs:341`(from_str 为 Value) |
|
||||
| tunnel 收 TunnelCommand | `crates/df-tunnel/src/tunnel.rs:407-454`(parse_command_from_broadcast) + `events.rs:119-132` |
|
||||
| miniapp 发 MiniCommand | `apps/df-miniapp/src/types/relay.ts:88-93` + `useAiChat.ts:379-385` |
|
||||
| AiChatEvent emit 散布 55 处 | grep `emit("ai-chat-event"` 跨 7 文件 55 次 |
|
||||
| AiSession 真多会话 | `src-tauri/src/commands/ai/mod.rs:348-383`(per_conv HashMap)+ `:632-716`(PerConvState) |
|
||||
| 审批挂起 + 恢复 | `src-tauri/src/commands/ai/agentic/mod.rs:1446`(return 挂起)+ `commands/chat.rs:521`(try_continue 恢复) |
|
||||
| EventBus 骨架未接 | `src-tauri/src/commands/ai/event_bus.rs:53`(dead_code)+ `:18`(批2接入留注) |
|
||||
| 前端 listen 单事件名 | `src/api/ai.ts:258`(listen('ai-chat-event')) |
|
||||
185
docs/05-代码审查/aichat-会话实测分析-2026-06-22.md
Normal file
185
docs/05-代码审查/aichat-会话实测分析-2026-06-22.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# AI Chat 真实会话实测分析(上下文 / 工具 / Agent 行为)
|
||||
|
||||
> 日期:2026-06-22 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow-dev.db`)实测会话 `84dc36c8` 全量 86 条消息反推,Python 脚本统计(`.temp/analyze_session.py`)
|
||||
> 基线:会话 prompt **1,320,713** / completion **7,578** tokens(completion/prompt 比 0.57%)
|
||||
> 产物:**3 P0 / 3 P1 / 2 P2**
|
||||
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
|
||||
> 关联:[[devflow-compress-ui-no-fold]] [[devflow-generating-statemachine]] [[aichat-techdebt-audit-2026-06-21]] [[code-review-anti-contamination]] [[devflow-aichat-streaming-md]]
|
||||
|
||||
---
|
||||
|
||||
## 会话概况
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| conversation_id | `84dc36c8-b416-44bc-a7c6-c4e00de82b6f` |
|
||||
| 标题 | `D:\Downloads\kms` |
|
||||
| 模型 | glm-5.2 |
|
||||
| 跨度 | 2026-06-22 01:29 ~ 12:23(中间大间隔,有效工作段 ~11:57–12:23) |
|
||||
| 消息数 | 86 |
|
||||
| prompt / completion tokens | 1,320,713 / 7,578 |
|
||||
| completion/prompt 比 | **0.57%**(极端输入重) |
|
||||
| 工具调用 | patch_file **16** / read_file **10** / list_directory 3 / grep 1 |
|
||||
| tool 返回总量 | **194KB 跨 41 条** |
|
||||
| `parts` 字段 | 0/86(全 null) |
|
||||
| 用户催促次数 | 7(继续×2 / 完成了吗×2 / 仔细检查 / 核对 收尾 / 核对 内容) |
|
||||
| 任务 | 整理 `D:\Downloads\kms` 下 README/INDEX + 三知识库 HTML 头尾修复 |
|
||||
|
||||
---
|
||||
|
||||
## P0(3 项)
|
||||
|
||||
### [P0-1] 上下文管理实质失效:压缩标记 `compressed` 未降 prompt,token 累积至 1.32M
|
||||
|
||||
- **现象**:seq 0–65 `status=compressed`(仅个别 active),但最终 `prompt_tokens=1,320,713`。压缩"生效"了,prompt 却没降。
|
||||
- **db 证据**:全量 86 条中 66 条 `status='compressed'`;conv 元数据 `prompt_tokens=1320713 / completion_tokens=7578`。
|
||||
- **根因假设**:`compressed` 仅压历史消息的展示态/落库态,**未真正削减送入 LLM 的 prompt**。与 [[devflow-compress-ui-no-fold]](2026-06-18 决策"后端省 token 不前端藏")冲突——决策要求后端真省,实测后端没省。
|
||||
- **影响**:1.32M 接近模型上下文上限,长会话必爆;成本几乎全烧在重复搬运文件内容。
|
||||
- **待核验源码点**:compress 模块(`src-tauri/src/commands/ai/` 下 compress 相关)——压缩后重组 messages 时是否真替换为摘要,还是仅打标记原样回灌。
|
||||
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] 3-compress 单元的 2 个 smell。
|
||||
|
||||
### [P0-2] 工具返回值协议不一致:`patch_file` / `read_file` 部分路径裸吐文件全文
|
||||
|
||||
- **现象**:同一工具返回结构两副面孔:
|
||||
- seq 16 patch_file → `{"changed":true,"diff":...}` ✅ JSON 正常
|
||||
- seq 37/40/42/46/49/51/53/60/63/65/75/76 patch_file → `raw 6204c` ❌ 裸文本(parse 失败)
|
||||
- seq 6/7 read_file → `{"content":...}` ✅ 正常
|
||||
- seq 11/29/31/33 read_file → `raw 6204c` ❌ 裸文本
|
||||
- **db 证据**:41 条 tool 返回总计 194KB;`6204` 字符数反复出现(≈被操作 HTML 文件大小)。
|
||||
- **根因假设**:`patch_file` 成功路径本应只回 `changed`+`diff`,却把整个文件内容回灌;或某分支(超长 / 特定路径)跳过 JSON 包装直接返回文件串。`6204c` 即文件实际长度被全文塞回 context。
|
||||
- **影响**:**这是 prompt 1.32M 的直接元凶**。每次 patch 一次 = 文件全文再进一遍 context。16 次 patch × ~6KB ≈ 100KB 仅 patch 回灌。
|
||||
- **待核验源码点**:`tool_registry.rs` patch_file handler(~1247 区,[[devflow-patch-file-design]] 落地点)与 read_file handler(~892-976)的返回序列化分支——确认哪条路径丢 JSON 包装。
|
||||
- **关联**:与 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」同源(工具契约失配家族)。
|
||||
|
||||
### [P0-3] 重复 tool 结果:同一结果多次持久化,去重缺失
|
||||
|
||||
- **现象**:
|
||||
- seq 11/12/13/14 → 连续 4 条 `raw 6204c`,同一 read 结果存 4 份
|
||||
- seq 69/70/71 → 3×6204c;seq 83/84/85 → 3×6204c
|
||||
- seq 75/76 → 2×6204c
|
||||
- **db 证据**:上述 seq 的 content 长度完全一致(6204),且时间戳相同(同一秒)。
|
||||
- **根因假设**:一次 assistant 回合并行触发多个 read(读 mysql+redis+linux 三文件),结果落库时按 tool_call 重复写,或同一 `tool_call_id` 结果被多次持久化;前端流式接收去重逻辑缺失。
|
||||
- **影响**:context 进一步虚胖;且会污染后续压缩/摘要(同一内容被当作多条独立事实)。
|
||||
- **待核验源码点**:tool 结果落库路径(`ai_messages` 写入)——按 `tool_call_id` 是否做唯一约束/去重;流式 patch 合并逻辑。
|
||||
|
||||
---
|
||||
|
||||
## P1(3 项)
|
||||
|
||||
> **总纲(用户归纳)——编排规划能力弱**:P1-1(无自检)与 P1-3(无并行调度)同源。agent 无任务分解、无并行 fan-out、无宣称完成前的自检规划,单链 ReAct 逐工具串行推进,识别不出"N 个独立子任务可并行"。这是 agent 自主性/可靠性的核心缺口,印证 [[aichat-decision-capability]] 与 [[aichat-b-route-parallel-multiround]]。归 B 路线立项。
|
||||
|
||||
### [P1-1] Agent 过早宣称完成,无 checklist 自检,用户被迫 7 次催促
|
||||
|
||||
- **现象**:assistant 3 次宣称完成,3 次被用户推翻:
|
||||
|
||||
| seq | 时刻 | 宣称 | 推翻证据 |
|
||||
|---|---|---|---|
|
||||
| #26 | 12:01 | "整理完成" | #27 用户"文档内容需要按要求整理" |
|
||||
| #66 | 12:10 | "**全部完成了!**" | #67"仔细检查" → #72 发现 `theme.js` vs `claude-artifact-theme.js` 引用名错(漏改 2 个文件) |
|
||||
| #80 | 12:19 | "全部核对完毕,7 个标记全部对齐无遗漏" | #81"核对 内容"(用户不信,要求再核)→ 会话中断于此 |
|
||||
|
||||
- **db 证据**:`theme.js` 引用名错误本可一次 grep 抓出,却要用户肉眼挑(实际 #78 才首次用 grep,且是用户第三次催后)。
|
||||
- **根因假设**:无宣称完成前的强制自检机制(如 grep 引用一致性 / 闭合标记核对)。Agent 倾向于 patch 完即宣布成功,不验证。
|
||||
- **影响**:用户对 agent 完成度零信任,交互成本极高;本质是 agent 自主性/可靠性的核心缺口。
|
||||
- **待核验/落点**:属 agent 行为策略层(prompt / loop 收尾钩子),非单点代码 bug。建议与 [[aichat-b-route-parallel-multiround]] B 路线(决策能力)合并立项。
|
||||
- **关联**:[[aichat-decision-capability]](单链 ReAct,无规划式智能)的现实印证。
|
||||
|
||||
### [P1-2] 末尾会话中断:tool 全部返回后无 assistant 回复(generating 状态机卡死)
|
||||
|
||||
- **现象**:#82 assistant 触发读三文件 → #83/84/85 tool 全部正常返回(`status=active`)→ **之后再无 assistant 消息**,会话 12:23 停尸。
|
||||
- **db 证据**:seq 85 是最后一条,role=tool;无 seq 86 的 assistant 总结回复。`active` 状态说明 tool 执行完成,但后续生成未发生。
|
||||
- **根因假设**:tool 执行完但 `generating` 状态未复位,后续回复被吞。高度吻合 [[devflow-generating-statemachine]] 记录的"generating 复位散布 + 无 panic 兜底"卡死模式。
|
||||
- **影响**:用户看到"读完了"就没下文,必须手动重发;且该会话 token 已 1.32M,重发极可能直接触上限。
|
||||
- **待核验源码点**:agentic loop 的 tool-后-续生成衔接(`mod.rs` 收尾 / `try_continue` / per-conv generating 复位)。
|
||||
|
||||
### [P1-3] 三个独立文件整理任务严格串行,无并行 fan-out 调度
|
||||
|
||||
- **现象**:`mysql_guide` / `redis_guide` / `linux_ubuntu_guide` 三个 HTML 整理是独立子任务(无依赖),本可并行。但 agent 严格串行:mysql(12:02–12:06)→ redis(12:07–12:08)→ linux(12:08–12:10),**时段完全不重叠**;每个文件内还 read→patch→read→patch 多轮。assistant 从未在一个 turn 内对三文件 fan-out 工具调用。
|
||||
- **db 证据**:时间线三文件处理时段分明不重叠;seq 68"仔细检查"后只 read mysql 一个,未并行触发 redis/linux 的核对。
|
||||
- **根因假设**:单链 ReAct 无任务分解层。缺"识别 N 个独立子任务 → 同 turn 并行发起 N 路工具调用"的编排能力。
|
||||
- **影响**:耗时线性叠加;同一指令在 context 里反复加载三遍;用户等待感差。**直接印证 [[aichat-b-route-parallel-multiround]]**(用户 2026-06-20 已决策"单对话并行多轮必须做")。
|
||||
- **归并**:与 P1-1 同属"编排规划能力弱"(见 P1 区总纲),归 B 路线。
|
||||
|
||||
---
|
||||
|
||||
## P2(2 项)
|
||||
|
||||
### [P2-1] system 消息污染 + 时序错乱
|
||||
|
||||
- **现象**:
|
||||
- seq 0 `role=system`,content 却是 assistant 语义残留:"我需要查看 mysql\_guide.html 的最后几行来确认尾部内容..."
|
||||
- seq 0 timestamp `12:08:02`,seq 1 却是 `01:29:35`——seq 0 比 seq 1 晚 10 小时。
|
||||
- **根因假设**:system/上下文注入消息的 role 标记错位;timestamp 用"插入时刻"而非"会话时刻"。
|
||||
- **影响**:排序、压缩、计费全受污染;system 消息混入 assistant 内容会误导模型。
|
||||
- **待核验源码点**:system prompt 注入路径(`ai_messages` 写入 system 行的位置)+ timestamp 赋值逻辑。
|
||||
|
||||
### [P2-2] 同文件反复 read,无缓存 / 无 diff 感知
|
||||
|
||||
- **现象**:`mysql_guide.html` read **4×**、`linux_ubuntu_guide.html` read **4×**;patch_file 共 16× 改 3 个文件。read→patch→read→patch 循环。
|
||||
- **根因假设**:每次 patch 后又全文 read 确认(叠加 P0-2 全文回灌);无 session 级文件缓存或"patch 后不重读"约定。
|
||||
- **影响**:context 雪崩放大器;与 P0-2 叠加是 prompt 爆炸的复合原因。
|
||||
- **待核验/落点**:工具侧可加"会话内文件指纹缓存,LLM 重读同文件时返回缓存命中提示";或 prompt 侧约束 patch 后不重读。
|
||||
|
||||
---
|
||||
|
||||
## 附录:`parts` 字段全空(待观察项)
|
||||
|
||||
- **现象**:`msgs_with_parts=0/86`。[[devflow-aichat-streaming-md]] 记录块级 memo(memo=mapped.lexer+v-memo)已落地(ARC-260615-08)。
|
||||
- **不定论**:本会话可能早于改造,或 `parts` 存于别处 / 未回填。需对照**新产生**的会话核验,不能据此断定流式渲染回退。
|
||||
|
||||
---
|
||||
|
||||
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
|
||||
|
||||
| 项 | 落点 |
|
||||
|---|---|
|
||||
| P0-1 / P0-2 / P0-3 | 并入 [[aichat-techdebt-audit-2026-06-21]] P1 主线,或单列"上下文/工具返回"专项 |
|
||||
| P1-2 中断 | 印证 [[devflow-generating-statemachine]] 待落地链(已有详案) |
|
||||
| P1-1 自检 + P1-3 并行调度 | 同属「编排规划能力弱」总纲,归 [[aichat-b-route-parallel-multiround]] B 路线(任务分解 + 并行 fan-out + 宣称前自检) |
|
||||
| P2-1 / P2-2 | 低优登记 todo |
|
||||
|
||||
**最高杠杆**:P0-2(工具返回全文回灌)——改一处(工具序列化返回 diff/changed 而非全文),prompt 直接降一个量级。
|
||||
|
||||
---
|
||||
|
||||
## 复现方法
|
||||
|
||||
```bash
|
||||
# 分析脚本(已留存)
|
||||
python E:/wk-lab/devflow/.temp/analyze_session.py
|
||||
|
||||
# DB 只读连接
|
||||
sqlite3 "file:%APPDATA%/top.1216.devflow/devflow-dev.db?mode=ro" -uri
|
||||
# 表:ai_conversations / ai_messages / ai_tool_executions
|
||||
```
|
||||
|
||||
数据快照:2026-06-22 12:36 抓取,会话可能仍在变化(WAL 活跃)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-23 修正与落地(自主推进,290 src-tauri + 324 df-ai 测试全过)
|
||||
|
||||
**核验推翻**(实测 DB tool_call_id + timestamp 独立核验,[[code-review-anti-contamination]]):
|
||||
- **P0-3 撤销**:两会话(4ae73423/84dc36c8)"重复 tool 结果"实测 tool_call_id **全不同** + timestamp 间隔数分钟/小时 → LLM 不同时刻重调,非落库去重 bug。原 P0-3 误判(06-22 报告 + 核验 agent 都错,实测数据证伪)。
|
||||
- **P0-2 性质修正**:非"prompt 爆炸元凶"(prompt 用内存完整态,非落库截断版)。真因是 `conversation.rs:72` truncate_for_persist 对**序列化 JSON 中间截断**插裸 `\n\n[...省略 N 字符...]\n\n`,破坏 JSON 致**落库坏 JSON**(reload/审计 parse FAIL,pos3072=TRUNCATE_HEAD)。
|
||||
- **中断A 非bug**:续轮机制完整(mod.rs:1362-1478),84dc36c8 seq85 后无回复疑达 max_iterations 或自然收敛。
|
||||
- **中断B 真缺陷**:4ae73423 末尾 advance_task 审批 `__PENDING__` 卡死(mod.rs:1464 return 无超时),非 generating 卡死。
|
||||
|
||||
**本次修复落地**:
|
||||
|
||||
| 项 | 位置 | 修复 |
|
||||
|---|---|---|
|
||||
| intent 收敛误杀(4ae73423 主因) | intent.rs | L0 strip_mention_tags 剥离 mention 前缀 + L1 Project/Task/Idea 加 File domain |
|
||||
| write_file parent 误拒(P0,授权内文件被拒) | tool_registry.rs:1114/1421/1559 | 改 allowed_dirs.is_authorized(脱 workspace_root) |
|
||||
| read_file limit 忽略 | tool_registry.rs:1023 | 无 offset 尊重 LLM limit(原固定 500) |
|
||||
| truncate 破坏 JSON(P0-2) | conversation.rs:175 | 跳 tool role 截断 |
|
||||
| 压缩丢末条消息 | agentic/mod.rs:1225 | 全 provider 失败 return 前补 save_conversation |
|
||||
| run_command && PS5 失败 | df-execute/shell.rs | pwsh(PS7)优先探测 + PS5 兜底(OnceLock 缓存) |
|
||||
| remote_bridge args default | remote_bridge.rs:97 | custom default 返空 Object(#[serde(default)] 给 Null) |
|
||||
| remote_bridge r1 双轨测试 | remote_bridge.rs:872 | 设态对齐双轨(conv_state=Generating) |
|
||||
|
||||
**写 docs/待决策.md**(非确定性,需决策):
|
||||
- BUG-260623-03 审批 pending 无超时(中断B,推荐 b 前端倒计时)
|
||||
- ARC-260623-01 workspace_root 写死残留(delete_file trash/resolve/run_command working_dir,分发适配)
|
||||
|
||||
**生效**:需重启 devflow app(cargo tauri dev / 重新 build),新会话才用新二进制。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 323 KiB |
156
docs/todo.md
156
docs/todo.md
@@ -50,6 +50,16 @@
|
||||
|
||||
---
|
||||
|
||||
### 🔍 2026-06-21 查询能力缺口 + run_workflow 缺陷(本轮排查登记·未实施)
|
||||
|
||||
> 用户排查任务/项目/灵感查询能力现状,登记缺口为待办/缺陷,本轮不实施代码。详单见 [查询能力补全方案-2026-06-21.md](./02-架构设计/专项设计/查询能力补全方案-2026-06-21.md)。
|
||||
|
||||
- [x] BUG-260621-01 [P1🔴·确定修法] — **run_workflow 空 dag 反序列化失败(missing field nodes),阻塞任务工作流推进** ✅ **2026-06-22 修复**:DagDef.nodes/edges 各加 `#[serde(default)]`,`{}` 反序列化成空 DagDef → workflow.rs:91 `is_empty()` 进 template_for 选模板。2 单测防回归(空 `{}` + 非空零回归),df-workflow cargo test 51 passed。— `crates/df-workflow/src/dag_def.rs` + `src/views/TaskDetail.vue`(:333)
|
||||
|
||||
- [x] F-260621-02 [P2] — **任务/项目/灵感列表查询维度补全(下沉后端+关键词搜索+排序分页)** ✅ **2026-06-22 实施**:三实体新增 `list_by_query` 动态 WHERE(累积式 where_clauses+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制;命令层 `list_{tasks,projects,ideas}` 吃 `Option<XxxQuery>` 双参向后兼容(旧无参/单参路径等价全量);前端 Tasks/Ideas status/keyword 筛选下沉后端 query(Tasks.vue buildTaskQuery / Ideas.vue watch 防抖)。索引缺口已补(2026-06-22 V26 migrate_v26 idx_tasks_priority/assignee + SCHEMA 同步,commit 82059ba)+ P3 分页 UI 已落(Paginator.vue 客户端切片 + Tasks/Ideas/Projects 三视图接,commit 83cee61)。详见查询能力补全方案-2026-06-21.md。— `commands/{task,project,idea}.rs` + `crates/df-storage/src/crud/{task,project,idea}_repo.rs` + `src/api/{task,project,idea}.ts` + `src/views/{Tasks,Projects,Ideas}.vue`
|
||||
|
||||
---
|
||||
|
||||
### 📋 编排推进总览(2026-06-18 更新)
|
||||
|
||||
未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录):
|
||||
@@ -257,8 +267,8 @@
|
||||
|
||||
**🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**:
|
||||
|
||||
- [ ] ARC-260618-01-d [P1/行为变更·待确认产品意图] — **conditions 条件引擎未求值**。`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
|
||||
- [ ] ARC-260618-01-e [P1/行为变更·待确认产品意图] — **adversarial `evaluate_with_llm` 一致性未校验**。`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
|
||||
- [ ] ARC-260618-01-d [P1/🟡已实现待启用] — **conditions 条件引擎**(核验2026-06-22:conditions.rs:31 ConditionEngine + executor.rs:106 已调 evaluate 求值 + executor.rs:95 `cfg!(feature="conditions-eval")` feature flag 控制,求值逻辑已实现待产品确认启用时机)。`df-workflow/conditions.rs` `ConditionEngine` 零消费(无调用方),`Edge.condition` 字段存储但工作流执行器未求值分支——边条件当前形同虚设。**产品决策点**:① 实现条件求值(executor 在节点完成后求值出边 condition 决定路由)还是 ② 标 `#[allow(dead_code)]` + 文档明示"条件为预留未启用"。涉及行为变更(executor 控制流改变),确认产品意图后再实施。对齐 H 组长期池 T-14-11 条件引擎(line 42 仅指针无详情)。— `crates/df-workflow/src/conditions.rs`(ConditionEngine) + `crates/df-workflow/src/executor.rs`(边求值点)
|
||||
- [x] ✅(2026-06-22 核验·adversarial.rs:147 `self.recommendation_for(&analyst.final_assessment)` 已守卫 final_assessment/recommendation 一致性,矛盾组合经 recommendation_for 收敛·漏勾销账) **ARC-260618-01-e** — adversarial evaluate_with_llm 一致性。已有守卫。`df-ideas/adversarial.rs` `evaluate_with_llm` 返回的 `final_assessment`/`recommendation` 两个字段语义一致性未校验(如 final_assessment=强烈反对 但 recommendation=promote 的矛盾组合无守卫)。LLM 输出存在字段间语义漂移风险。**产品决策点**:① 加一致性校验(矛盾时降级或重评)还是 ② 视为 LLM 自由表达不加约束。涉及行为变更(评估结果可能被改写),确认产品意图后再实施。— `crates/df-ideas/src/adversarial.rs`(evaluate_with_llm 返回结构)
|
||||
- [x] ✅(2026-06-20·formatRelativeZh→formatRelative 重命名 6 文件:time.ts 定义+注释去「中文」限定 + ConversationSidebar/MessageList/ActiveProjectsPanel/AuditLog/Tasks 5 调用点·AiChat 已拆 750 行无引用·grep 全 src 无残留 + vue-tsc --noEmit EXIT 0) SW-260618-21 [P2] — **formatRelativeZh 重命名 formatRelative**。`src/utils/time.ts` `formatRelativeZh` 名带 Zh 后缀但实际非中文硬编码(已读 i18n.global.locale, sweep 批2 time.ts formatDate 已修 CR-260615-08 漏修),函数名误导。原缓做前提(AiChat 沉淀)已满足,本次落地。— `src/utils/time.ts`(formatRelative) + 5 调用点(ConversationSidebar/MessageList/ActiveProjectsPanel/AuditLog/Tasks)
|
||||
- [x] ✅(2026-06-20 核验·resolveLang 已抽 `src/composables/ai/aiShared.ts:30-35 resolveAiLang` 共享,消除重复) SW-260618-22 [P2/已知 B-260617-03] — **useAiSend resolveLang DRY**。`useAiSend` 的 `resolveLang` 与其他 composable 语言解析逻辑重复,应抽 `aiShared` 共享。已知登记为 B-260617-03,此处补 sweep 维度记录便于检索。— `src/composables/ai/useAiSend.ts`(resolveLang) → 抽 `src/composables/ai/aiShared.ts`
|
||||
|
||||
@@ -425,7 +435,7 @@
|
||||
**P0(超红线/最脏,本周期优先)**
|
||||
|
||||
- [x] ✅(2026-06-20 核验已拆分·`commands.rs` 已删除→`src-tauri/src/commands/ai/commands/` 目录 5 模块:chat.rs(56888 字节·发送审批控制 B 域)/config.rs(5496·杂项 E)/conversation.rs(38573·会话CRUD D)/mod.rs(2058·re-export only,`pub use self::{chat,conversation,provider,config}::*` 透传)/provider.rs(15262·提供商 C)·glob 链保留:commands/mod.rs:13 注释明 `pub use self::{chat,conversation,provider,config}::*` → ai/mod.rs `pub use self::commands::*` 透传 commands::ai::*) **REFACTOR-260619-01 [P0]** — **commands.rs(ai,1923 行)拆 5 模块**。5 组职责正交:B 发送审批控制(1140,13 IPC)/C 提供商(305)/D 会话CRUD(360)/E 杂项(63)+A helper。**先拆 C `provider_cfg.rs`(零风险试水:仅依赖 ai_providers 无 session 锁/per_conv/spawn)→ D `conversation_crud.rs`(`conversation.rs` 已存在,命名避冲突)→ E `misc_cfg.rs`→ B `chat_control.rs`(最复杂,等 F-09 B 批4 更稳)**。关键:`pub use self::commands::*;` glob 保留(mod.rs:58)否则 invoke_handler 注册断。— `src-tauri/src/commands/ai/commands.rs` + `mod.rs:58`
|
||||
- [ ] **REFACTOR-260619-02 [P0]** — **anthropic_compat.rs(1025 行)拆模块**。含本会话加的 precheck/summarize(诊断)+ convert_request(159)+ SSE 解析(109)。**保守拆 3 模块**:`types.rs`(请求响应结构体 ~90)+ `sse.rs`(apply_anthropic_event ~160+测试)+ 残留 provider.rs(convert/complete/stream/precheck 留 impl 块不拆,因 Rust impl 不跨文件)。激进拆 4(加 convert.rs)需把 convert_request 等 4 关联 fn 改自由函数(动 6 处 Self:: 调用点),风险高不推荐。— `crates/df-ai/src/anthropic_compat.rs`
|
||||
- [x] ✅(2026-06-22 核验·types+SSE 已抽 crates/df-ai/src/anthropic_helpers.rs(5 struct+2 const+apply_anthropic_event),anthropic_compat.rs 残留 881 行 Provider+impl(Rust 硬约束 impl 不跨文件不可再拆),lib.rs:4-5 双模块注册·对齐 _compat/_helpers 同构约定(openai/model_fetch/model_probe/context 同)·漏勾销账) **REFACTOR-260619-02** — anthropic_compat.rs 拆模块。已完成(types+SSE 抽出)。
|
||||
- [x] ✅(2026-06-20 核验已拆分·`audit.rs` 已删除→`src-tauri/src/commands/ai/audit/` 目录:mod.rs(31813 字节·process_tool_calls 热路径 + 编排)/reason.rs(7796·build_approval_reason)/finalize.rs(3874·audit_tool_call+audit_finalize,mod.rs:115 `pub(crate) use finalize::{audit_finalize, audit_tool_call}`)/cache.rs(7685·find_cached_high_risk_result)/diff.rs(1686·build_write_file_diff)/restore.rs(10033·restore_pending_approvals)/idea_source.rs(13607·任务→灵感溯源)/data_change.rs(2432)/utils.rs(1486·risk_str/常量)) **REFACTOR-260619-03 [P0]** — **audit.rs(959 行)拆 5 模块**。`audit/{mod,list,reason,finalize,dedup,process}.rs`。`process_tool_calls`(250+行热路径)**整块搬 process.rs 不拆函数体**(锁内 await 边界不动,CR-260618-11#5 性能注记)。11+ 私有 fn 升 pub(super):audit_tool_call/audit_finalize/find_cached_high_risk_result/build_write_file_diff/build_approval_reason 等。`PENDING_APPROVAL_PLACEHOLDER`/`risk_str` 提 mod.rs 共享。— `src-tauri/src/commands/ai/audit.rs`
|
||||
|
||||
**P1**
|
||||
@@ -434,12 +444,12 @@
|
||||
- [x] ✅(2026-06-20 核验已拆分·`agentic.rs` 已删除→`src-tauri/src/commands/ai/agentic/` 目录:mod.rs(83390 字节·run_agentic_loop 主 loop)/guard.rs(2894·GeneratingGuard+ContinueSnapshot 抽出对齐 A+E+F `agentic_runtime`)·stream_recv.rs(独立文件,StreamOutcome+stream_one_provider 对齐 B+C `agentic_stream`)) **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。主 loop 是**单函数 720 行不可按函数拆**(工具执行/审批是 loop 内 if 分支;process_tool_calls 在 audit.rs)。先抽 A+E+F `agentic_runtime.rs`(GeneratingGuard+try_continue_agent_loop+ContinueSnapshot,~195,最高收益最低风险)+ B+C `agentic_stream.rs`(StreamOutcome+stream_one_provider,~165)。**D run_agentic_loop 等 F-09 B 批4 落地再评估**(避免 per_conv 双线作战)。— `src-tauri/src/commands/ai/agentic.rs`
|
||||
- [x] ✅(2026-06-20 核验·已拆:ai_node.rs 423 + ai_self_review_node.rs 447 + ai_helpers.rs 298,测试分块清晰) **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。`ai/{mod,params,ai_node,self_review}.rs`。params.rs(provider 解析 helper ~270)+ ai_node.rs(~130)+ self_review.rs(~300)。测试分块清晰(752/939/1069 三段),低风险。fixture provider_stub/config_with 留 params.rs `pub(super)`。— `crates/df-nodes/src/ai_node.rs`
|
||||
- [x] ✅(2026-06-20 核验已拆分·`src/components/ai/` 子目录已存在子组件:ConversationSidebar.vue + ChatInput.vue + MessageList.vue + TopBar.vue + DirAuthDialog.vue + MaxRoundsCard.vue·AiChat.vue 从 4075 行缩到 750 行·状态/composable 已外移,模板拆分完成) **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。方案已定,状态/composable 已外移,拆 template+局部 script。前置:先提交工作区未提交改动(本会话 shouldRenderMsg/scroll/1214 预检等)。— `src/components/AiChat.vue`
|
||||
- [ ] **REFACTOR-260619-08 [P1]** — **(已有 SMELL-P0-2)tool_registry.rs(2023)按功能分组注册函数拆**。register_crud_tools/register_file_tools(已抽)/register_workflow_tools 等,每个 <200 行。— `src-tauri/src/commands/ai/tool_registry.rs`
|
||||
- [x] ✅(2026-06-22 核验·tool_registry.rs:442-514 已拆 register_*_tools 系列 data/file/http/project/task/workflow/idea/trash,SMELL-P0-2 批次落地·漏勾销账) **REFACTOR-260619-08** — tool_registry.rs 按功能分组注册函数拆。已完成。register_crud_tools/register_file_tools(已抽)/register_workflow_tools 等,每个 <200 行。— `src-tauri/src/commands/ai/tool_registry.rs`
|
||||
|
||||
**P2 暂缓(窗口未到/收益低)**
|
||||
|
||||
- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。impl 跨文件方案(同 crate 多 impl 块,零字段可见性改动)。先 sanitize.rs(最大连续块 ~184)。当前 context.rs 被 F-15/压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs`
|
||||
- [ ] **REFACTOR-260619-10 [P2]** — **scan.rs(1015)拆 4 模块**(stack/discover/readme/sample)。纯函数低风险收益低。共享 SAMPLE_IGNORED_DIRS/truncate_chars/read_readme_raw 提 mod.rs。— `crates/df-project/src/scan.rs`
|
||||
- [x] ✅(2026-06-22·scan.rs(实际741行)拆 scan/{mod,stack,discover,readme,sample}.rs,共享 SAMPLE_IGNORED_DIRS/truncate_chars 提 mod.rs pub(super),has_file_with_ext/read_readme_raw 跨模块降 pub(super),测试 29→29 不降,cargo check --workspace EXIT 0 外部消费方零改) **REFACTOR-260619-10** — scan.rs 拆 4 模块。已完成。
|
||||
- [x] ✅(2026-06-20 核验·`crud.rs` 已删→`crates/df-storage/src/crud/` 5 文件 mod/conversation_repo/idea_repo/message_repo/project_repo·与 SMELL-P1-9 同件) **(已有 SMELL-P1-9)crud.rs(2212)按表拆** project_repo/task_repo/conversation_repo/idea_repo。— `crates/df-storage/src/crud.rs`
|
||||
|
||||
**执行顺序建议**:01-C provider_cfg(零风险试水) → 02 anthropropic → 03 audit → 04 ToolCard useToolFormat → 05 agentic_runtime → 06 ai_node → 07 AiChat.vue → 08 tool_registry。每步 cargo check+test+clippy 分 crate + 手测。
|
||||
@@ -452,7 +462,7 @@
|
||||
|
||||
- [x] ✅(2026-06-19·df-execute shell.rs 全仓唯一子进程源(grep 确认)·tokio Command 加 creation_flags(0x0800_0000) CREATE_NO_WINDOW·cargo check df-execute EXIT 0 无 warning) **B-260619-01 [P1]** — **执行命令行弹黑色窗口闪烁**。Windows tokio::process::Command 创建 cmd/powershell 子进程默认带控制台窗口(黑窗闪现)。修:`#[cfg(windows)] cmd.creation_flags(0x0800_0000)`(CREATE_NO_WINDOW)。tokio Command Windows 自带 creation_flags 方法(无需 std CommandExt trait)。覆盖 run_command 工具 + 工作流 shell 节点(全经 df-execute)。— `crates/df-execute/src/shell.rs`(:86-93)
|
||||
|
||||
- [ ] **B-260619-02 [P1]** — **GLM 1214 messages 非法(数据驱动定位:单条 tool_result 过大)**。DB 直查报错对话 f64dee94:全量 546 条(多数 compressed),**active(发往GLM)仅 7 条结构合法**(system/assistant/user/assistant/user 交替,tool_use↔tool_result 配对 orphan=0,无连续 role,57K 字节 ~19K tokens **未超 GLM 128K**)。**真凶**:单条 tool_result 巨大(read_file 整文件 #2=21843B/#3=16511B/#6=11482B),T-05 截断阈值 50KB 未触发(21KB<50KB),但 **GLM anthropic 端点单条 tool_result content 限制更严(~10-20KB 即拒 1214)**。次要:#4 assistant content 空(len=0 只有 tool_use);"回复一半消失"=1214 在 GLM 流极早(message_start 前)error→stream_recv InitFailed→emit AiError→前端清 currentText,保文逻辑未覆盖。**修法方向**:① tool_result 截断阈值收紧 50KB→8-10KB(read_file/list_directory 大文件截断+提示)② MidStream 早 1214 保文(apply_anthropic_event error 时若已 message_start 不清流式)③ 空 assistant convert 兜底。**已加诊断(待重编译验证)**:anthropic_compat precheck(首条/连续/input/空content/orphan 5 类 Init bail)+ MidStream error 附 messages 摘要(SSE error chunk 塞摘要到前端 raw)+ HTTP/1.1 治 GLM HTTP/2 RST + 错误源链进 anyhow 文案。— `crates/df-ai/src/anthropic_compat.rs`(precheck/summarize/MidStream 摘要/version HTTP_1_1) + tool_result 截断(T-05 `tool_registry.rs`/`audit.rs` 阈值 50K→8-10K) + MidStream 保文(`stream_recv.rs`)
|
||||
- [x] ✅(2026-06-22·conversation.rs:65 TRUNCATE_THRESHOLD 50KB→8KB + HEAD/TAIL 各3KB + 截断提示含原字节数让 LLM 感知非全文·cargo check devflow EXIT 0 + conversation 测试 25 passed·仅作用持久化视图不污染内存真相源) **B-260619-02** — GLM 1214 tool_result 过大截断。已修(大小根因,配 CR-260620-04 配对错根因互补)。DB 直查报错对话 f64dee94:全量 546 条(多数 compressed),**active(发往GLM)仅 7 条结构合法**(system/assistant/user/assistant/user 交替,tool_use↔tool_result 配对 orphan=0,无连续 role,57K 字节 ~19K tokens **未超 GLM 128K**)。**真凶**:单条 tool_result 巨大(read_file 整文件 #2=21843B/#3=16511B/#6=11482B),T-05 截断阈值 50KB 未触发(21KB<50KB),但 **GLM anthropic 端点单条 tool_result content 限制更严(~10-20KB 即拒 1214)**。次要:#4 assistant content 空(len=0 只有 tool_use);"回复一半消失"=1214 在 GLM 流极早(message_start 前)error→stream_recv InitFailed→emit AiError→前端清 currentText,保文逻辑未覆盖。**修法方向**:① tool_result 截断阈值收紧 50KB→8-10KB(read_file/list_directory 大文件截断+提示)② MidStream 早 1214 保文(apply_anthropic_event error 时若已 message_start 不清流式)③ 空 assistant convert 兜底。**已加诊断(待重编译验证)**:anthropic_compat precheck(首条/连续/input/空content/orphan 5 类 Init bail)+ MidStream error 附 messages 摘要(SSE error chunk 塞摘要到前端 raw)+ HTTP/1.1 治 GLM HTTP/2 RST + 错误源链进 anyhow 文案。— `crates/df-ai/src/anthropic_compat.rs`(precheck/summarize/MidStream 摘要/version HTTP_1_1) + tool_result 截断(T-05 `tool_registry.rs`/`audit.rs` 阈值 50K→8-10K) + MidStream 保文(`stream_recv.rs`)
|
||||
|
||||
- [x] ✅(2026-06-20·cargo df-ai+devflow EXIT 0 零 warning + vue-tsc EXIT 0·主 worktree 改,workflow worktree baseRef 故障绕过) **CR-260620-04 [P0]** — **1214/400 messages 非法·配对错根因(compress 绕过 sanitize)+ 停用模型路由穿透**。用户贴 deepseek 400「tool_calls must be followed by tool messages」/「tool must be response to tool_calls」确认 **tool_use/tool_result 配对错**(与 B-260619-02 tool_result 大小根因**互补,1214 多根因**)。**根因**:`sanitize_messages`(context.rs:212 畸形配对自愈,三档+孤儿)只在 `build_for_request` 调(主对话),**`compress_via_llm`(compress.rs:46)构造 prompt 不经它**(:70 extend active_msgs 直进)→ 压缩带毒 → 400「自动上下文压缩失败」。**修法**:① `context.rs:212 fn sanitize_messages`→`pub fn`(跨 crate);② `compress.rs` 空检查后 `let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs)` 治毒 + `:90` 错误带 `active_count` 诊断;③ 停用模型 `agentic/mod.rs:188,450` model_override `.any(|m|m.model_id==id)`→`==id && m.enabled`(会话内 stale override 不穿透 disabled)。**关联**:B-260619-02(tool_result 大小根因 GLM 1214,截断 50K→8-10K **仍待做** `:412`)/ F-09 newConversation(CR-260620-02 ✅)/ 用户实测 deepseek 400。**未做**:tool_result 截断(B-260619-02 大小根因,GLM 若还犯需做)+ stream_recv SSE 错误 summarize(诊断仅 compress 层,stream_recv 待补)。— `crates/df-ai/src/context.rs` + `src-tauri/src/commands/ai/compress.rs` + `src-tauri/src/commands/ai/agentic/mod.rs`
|
||||
|
||||
@@ -696,8 +706,8 @@
|
||||
|
||||
### ⚠️ 灵感升级-遗留缺陷(2026-06-21·待另一会话修)
|
||||
|
||||
- [P0 功能] related_ids 白名单缺失:crates/df-storage/src/crud/settings.rs:121-124 的 ideas 白名单(13 列)无 related_ids → 批3c 关联管理 Ideas.vue onUpdateRelated 调 update_field('related_ids') 被 validate_column_name 拒,关联存不进 DB(表面能选确定后静默失败)。修复:settings.rs:123 ideas 白名单加 "related_ids"(一行)。已核验确认。
|
||||
- [P2 并发·待核] 评估历史 version 并发重复:src-tauri/src/commands/idea.rs evaluate_idea 用 list_by_idea().first().version+1 算 version,无事务/(idea_id,version) 唯一约束,并发评估同一 idea 两次可能 version 重复。单用户桌面低概率,严格讲需加唯一约束或事务。批2 引入。
|
||||
- [x] ✅(2026-06-22 核验·settings.rs:123 ideas 白名单已含 related_ids,14 列非 13,批3c 落地时已补,关联 update_field 放行非静默失败·过时 todo 销账) **related_ids 白名单** — 原"P0 功能缺失"为过时信息,实际已落地。
|
||||
- [P2 并发·🟡部分完成] 评估历史 version 并发重复:idea.rs:288-293 evaluate_idea 已加重试兜底 + V25 (idea_id,version) 唯一约束(migrations.rs)兜底。约束+重试就位,运行时确认约束生效后销账。单用户桌面低概率,严格讲需加唯一约束或事务。批2 引入。
|
||||
- [P2 运行·未验] 批1-3c 前端运行时未实测:编译/vue-tsc 过,但运行(评估历史加载/关联管理交互/统计 computed/ProjectDetail 来源卡片反查 store.ideas)未实际跑过。需 npm run tauri dev 实测。
|
||||
|
||||
---
|
||||
@@ -719,7 +729,7 @@
|
||||
|
||||
> 用户并行开发工具结果展示时,`ToolResultBody.vue` + `composables/ai/useToolCard.ts` 引用了 `ToolResult` 类型不存在的字段(vue-tsc 7+ 错)。**非本次灵感模块改动引入**,是用户工作区并行开发中间态。不阻断 `vite dev`(dev 宽松),阻断 `npm run build`(prod 严格)。
|
||||
|
||||
- [ ] **TD-260621-07 [P2/预存债·待用户对齐]** — **ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**。
|
||||
- [x] ✅(2026-06-22 核验·useToolCard.ts:88-100 ToolResult 已含 output_mode/matches/files/counts/total_files 五字段(F-260621 grep 跨文件搜索)+注释说明三模式语义·ToolResultBody.vue:71-95 三模式渲染消费·useToolCard.ts:424-446 formatGrep 消费·vue-tsc --noEmit EXIT 0 无 TS 错·漏勾销账) **TD-260621-07 [P2/预存债·待用户对齐]** — **ToolResult 类型缺 output_mode/files/counts/matches/total_files 字段**。
|
||||
- **现状**:`ToolResult`(types.ts)未定义 `output_mode` / `files` / `counts` / `matches` / `total_files` 五字段,但 `ToolResultBody.vue` + `useToolCard.ts` 模板/computed 已引用 → vue-tsc 报 7+ Property does not exist
|
||||
- **定位**:用户并行开发工具结果展示增强(可能是为了批1 评估深度标签/批2 历史时间线之外的工具结果多模式渲染 — output_mode 暗示 tab/列表/树等多种展示模式),属进行中的中间态
|
||||
- **修法**:① `ToolResult` 类型补齐 5 字段(对齐后端返回结构) + ② `ToolResultBody.vue` / `useToolCard.ts` 消费方与类型对齐(可能需后端 `tool_result_summary` IPC 返回结构同步扩字段)
|
||||
@@ -743,7 +753,15 @@
|
||||
- **P3** df-miniapp 小程序(前端 UI 复用 + WSS 连云)— 依赖 P1/P2
|
||||
- **P4** 双向同步完善(冲突处理 + 安全 + 离线降级 + 部署)— 依赖 P1/P2/P3
|
||||
|
||||
**状态**:📋 待决策(晋升灵感 4495fbcd → 启动 P1?或暂缓单机版稳定后)
|
||||
**状态**:✅ Phase2 三层全落地(2026-06-22:df-relay `2b8b30e` Hello握手+鉴权+ConnRegistry配对路由+BroadcastMessage透传 / df-tunnel `25d6565` WS客户端出站穿NAT+心跳25s+指数退避+TunnelCommand 5变体 / df-miniapp `280baea` uni-app WS连relay透传+18变体AiChatEvent镜像+useAiChat分派;双 crate 独立消息骨架不依赖 src-tauri/df-types 避跨 crate 强耦合)。📐 **Phase3 联调设计已出** [F-260622-01](../docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md)(协议统一方案A纯透传 + AiSession桥接风险 + 4阶段路线),待实施。
|
||||
|
||||
**Phase3 联调待办**(D1-D6 决策点推荐:A纯透传 / 全19变体透传 / EventBus汇聚 / switch不处理 / 强类型保留 / 桥接层R1兜底):
|
||||
- [x] ✅(2026-06-22)F-260622-01-阶段1(D1=A):tunnel `CommandHandler` 收 `serde_json::Value` + `parse_payload_from_broadcast` 返回 Value(入站纯透传,cargo check + 5 测试过,D5 保留 TunnelCommand 弱校验)。**出站 `send_raw_event` 移阶段2**(无阶段1 验证场景,阶段2 EventBus 接入同步加)
|
||||
- [x] ✅(2026-06-22)F-260622-01-阶段2:前置决策 ② EventBus Value(0fb5af5)+ emit 双写全接入 chat19/audit12/guard3/stream_recv2/agentic18(612de4c)+ send_raw_event(030ddb6)+ tunnel subscriber(ed8e2fc)。**后端上行闭环**(EventBus→tunnel→relay→miniapp)。剩余:relay_url/device_id Settings UI(方案A)+ token 生产级(Phase4)
|
||||
- [x] ✅(2026-06-22)F-260622-01-阶段3:remote_bridge 桥接(0fb5af5)+ miniapp 4命令(8ccdce2)+ on_command 注册(ed8e2fc)。**下行闭环**(miniapp Command→tunnel on_command→remote_bridge→Tauri command)
|
||||
- [ ] F-260622-01-阶段4:真机联调 + 多会话并发验证矩阵(6 场景含 F-09 跨端并发)
|
||||
- [x] ✅ 核验项:ai_chat_send 内部 generating guard(chat.rs:354-358 有 `is_gen → Err<String>`);R1 仍加作双保险(桥接层前置 + 内部 guard 双层)
|
||||
- [x] ✅ 核验项:ai_chat_stop AwaitingApproval(chat.rs:1469-1484 finalize_pending_placeholders + retain 清 pending + generating=false + emit AiCompleted,R3 已缓解)
|
||||
|
||||
---
|
||||
|
||||
@@ -782,7 +800,7 @@
|
||||
|
||||
**关联**:`intent.rs`(CR-08)/ 跑题 P0 intent 接入(102d398)/ `coordinator.rs` 空壳(SMELL-P2-4)/ conditions(T-260614-11)/ F-09 B 路线 / memory [[aichat-arch-extensibility]] / [[aichat-roadmap-ab-split]](B 路线)
|
||||
|
||||
**状态**:📋 待决策(是否推进单对话内并行多轮?轻规划先 / 重规划独立?)。属 aichat B 路线(决策能力)范畴,非刚需(P0-P2 跑题已大幅改善,ReAct 单链覆盖大多数场景)。
|
||||
**状态**:🔧 Phase1 已落(2026-06-22 PLANNING_ENABLED=true 主 loop 规划开关启用 + filter_tool_defs_planned 接入点就绪 commit cf7fe86,plan_hint 函数 PLAN_HINT_ENABLED 就绪双层开关可回退)。Phase2 评估(2026-06-22 agent 核验):plan_hint 完整非空壳(4 启发式规则 plan_hint.rs:264-351,Phase1 已生效生产路径非占位),Phase2=接 planner.rs LLM Plan DAG 重写主 loop 调度(L 工作量+🔴高回归风险:主 loop 绑 F-09并发/DIRAUTH/generating状态机/断路器/压缩 6 子系统),**暂缓先验 Phase1 收益**(埋点 agentic/mod.rs:648-656 就绪跑数据)。📋 待决策(Plan 严格调度 vs hint 模式双语义未定 plan_hint.rs:20-25)。属 aichat B 路线(决策能力)范畴,P0-P2 跑题已大幅改善 ReAct 单链覆盖大多数场景。
|
||||
|
||||
---
|
||||
|
||||
@@ -831,3 +849,119 @@
|
||||
**关联**:CR-260620-02 审查 LOW-1 / F-09 决策e
|
||||
**状态**:✅ 已实施(删 aiChat.ts confirmNewConvWhileGenerating key zh/en + 改 TopBar/Sidebar 3 处 stale 注释,vue-tsc EXIT 0,2026-06-20)
|
||||
|
||||
---
|
||||
|
||||
### 🔍 2026-06-22 miniapp 功能走查(workflow wf_ddd49ac8 · 5 域并行核验 + 主代终态抽查)
|
||||
|
||||
> 用户 /review miniapp + workflow 汇总安排。5 agent 独立 grep/read 核验源码(不信注释/文档) + 主代终态核验(ws.ts 真路径 `src/api/ws.ts`、`relay.rs:255/355`、remote_bridge 命令数 10 非 11)。**18 findings → 去重 P1×5 / P2×3 / P3×1**,2 项 refuted。关联 F-260620-01 阶段4(真机联调前置)。**守 session-role-diagnose-only:本会话仅走查 + 登记,未实施代码。**
|
||||
|
||||
**P1(阻塞真机/可靠性·5 项)**:
|
||||
|
||||
- [ ] P1-D-260622 [P1🔴·真机分发最高阻断] — **token/deviceId 硬编码 + 无配对绑定 + 无 storage 持久化**。`config.ts:35-39` 硬编码本机 device_id+token(注释自认「联调期硬编码,后续做配对绑定」),`:52-54` setConfig 仅内存赋值无 `uni.setStorageSync`。真机分发不能用本机硬编码 device_id。修:settings 页输入 + setConfig 持久化 storage + getConfig 读回退 default。— `apps/df-miniapp/src/config.ts`
|
||||
- [ ] P1-A-260622 [P1🔴·可靠性] — **心跳 pong 闭环缺失**。`ws.ts:226` 发 `{control_kind:'ping'}`,`relay.rs:355` miniapp 入站全标 `MessageKind::Command` 透传 device,但 ping 无 cmd 字段 → `remote_bridge.rs:143` match 无 ping 分支 → device 收不到有效命令无 pong 回;`useAiChat.ts:440` onControl 仅 console.log 无 pong 处理;`ws.ts:245-257` 重连仅靠 onClose/onError 无心跳超时。移动网络 TCP 半连接高发,device 静默挂死无感知。修:relay 解析 ping 构造 Control 类型 + device 响应 pong + miniapp 维护 lastPongTime 看门狗(3×interval 未收 pong 主动重连)。— `ws.ts` + `relay.rs:handle_inbound_text` + `useAiChat.ts`
|
||||
- [x] P1-B-260622 [P1🔴·首屏连不上·✅2026-06-23核验已修] — **握手判定脆弱**。`ws.ts:185` `text.includes('"error"')` 字符匹配判握手失败(真机业务消息含 error 字符误判);同时 `ws.ts:181` 注释「relay 无显式 ack」错——`relay.rs:255` 实发 `{control_kind:"hello_ack"}` 但代码漏检。修:handleHandshakeResponse 改结构化解析——判 `msg.kind==='control' && msg.control_kind==='hello_ack'` 为成功,error 判 `msg.error` 字段存在性。— `apps/df-miniapp/src/api/ws.ts`
|
||||
- [x] P1-C-260622 [P1🔴·体验阻断·✅2026-06-23 核验已修] — **冷启动不恢复会话历史**。`App.vue:8-10` onLaunch 仅 connect,`chat/index.vue:62-64` onShow 仅 scrollToBottom,`useAiChat.ts:61` activeConversationId 初始 null → 真机首次打开空白。修:连接成功后 refreshConversations;chat onShow 中 activeConversationId=null 且有会话时自动选最近会话 + switchConversation(load_messages)。— `App.vue` + `chat/index.vue` + `useAiChat.ts`
|
||||
- [x] P1-E-260622 [P1🟡·多轮场景·✅2026-06-23] — **continueLoop/stopLoop 无 UI 入口**。`useAiChat.ts:699-700` 已导出,`useAiChat.ts:241-250` AiMaxRoundsReached 仅 clearWatchdog + 系统气泡「请去桌面端处理」,chat/index.vue 全文无调用。多轮场景用户真机无法继续/停止。修:max rounds 时 chat 渲染继续/停止按钮调 continueLoop/stopLoop。— `apps/df-miniapp/src/pages/chat/index.vue`
|
||||
|
||||
**P2(UX/健壮性)**:
|
||||
|
||||
- [x] P2-B-260622 [P2·quickfix·✅2026-06-23核验已修] — **deviceOnline 离线不复位**。`useAiChat.ts:76-77` 注释自认「MVP 不做超时复位」,全 src 无 `deviceOnline.value=false`,onStatus 断开/重连只 clearWatchdog。device 断线状态滞后。修:onStatus 回调 disconnected/reconnecting 加 `deviceOnline.value=false`(一行)。— `useAiChat.ts`
|
||||
- [x] P2-C-260622 [P2·quickfix·✅2026-06-23] — **路由表注释陈旧(双侧)**。`types/relay.ts:86` 称「8 条」实际 match 10 条;`remote_bridge.rs:13-25` 注释列 9 条实际 10(F-#95 扩展 list_conversations/load_messages 未同步注释)。修:双侧注释补 list_conversations/load_messages + 数字改 10。— `types/relay.ts:86-88` + `remote_bridge.rs:13-25`
|
||||
|
||||
**P3/defer**:
|
||||
|
||||
- [x] P2-A-260622 [P2·defer·✅2026-06-23] — **messages 内存态无上限(OOM)**。`useAiChat.ts:56` reactive 无 MAX_MESSAGE。联调短会话无风险,长会话再做(MAX_MESSAGES=100 + truncate 保留最新 50)。— **已修**:watch trim MAX_STORE_MESSAGES=500(渲染另有 visibleMessages slice 200 兜底)。
|
||||
- [x] P3-A-260622 [P3·defer·✅2026-06-23] — **输入框单行非 textarea**。`chat/index.vue:176-184` `<input type="text">`。MVP 单行可接受,多行场景再升级 textarea auto-height。— **已改 input→textarea auto-height**(max-height 120px + flex-end 对齐;⚠️ 原生层级遮联想浮层风险待用户实测,fallback cover-view)。
|
||||
|
||||
**已驳回(refuted·不记 todo)**:regenerate 双 user 气泡(UI 展示层非逻辑错,switchConversation 即清)/ 看门狗 130s(实现正确无需修)/ 审批卡+流式光标(完成项核对,非问题)。
|
||||
|
||||
**真机分发前置序(P1 按依赖)**:P1-D(token/配对)→ P1-A(心跳闭环)→ P1-B(握手解析)→ P1-C(冷启动恢复)→ P1-E(max UI)。P2-B/C 可同批 quickfix(注释对齐 + 一行复位)。
|
||||
|
||||
**用户补充核验(2026-06-22 用户详读源码+设计文档,主代逐项核源码判真伪)**:
|
||||
|
||||
> ⚠️ 用户分析 P0 三项(协议三重不匹配 / EventBus 未接 / 桥接层占位)**经源码核验全部失实**,系基于 Phase3 阶段1-3 落地前的设计文档「计划态」描述,未核当前代码。证据:
|
||||
> - **P0-1 协议**:`crates/df-tunnel/src/tunnel.rs:457/327/424` tunnel 纯透传 payload `Value` 上抛 on_command,**不**做 `from_value::<TunnelCommand>` 强类型反序列化(方案A 阶段1 已落地 commit 0fb5af5)→ miniapp `{cmd,args}` 反序列化在桥接层 `remote_bridge.rs:96 from_payload` 成功,非阻断。tunnel.rs:457 注释自证「不反序列化为 TunnelCommand 强类型」。
|
||||
> - **P0-2 EventBus**:`src-tauri/src/lib.rs:128` `state.ai_event_bus.subscribe()`(tunnel subscriber 已接)+ `remote_bridge.rs:303/332/358/388` + `commands/ai/mod.rs:209/225` `publish_event` 跨端透传。**已双写接入**(todo line 760 commit 0fb5af5/612de4c emit 双写 chat19/audit12/guard3/stream_recv2/agentic18)。`event_bus.rs:53/115` 的 `#![allow(dead_code)]`+「骨架阶段」注释是**过时历史标注**(publish_event 现有调用方 remote_bridge/mod),非未接入。
|
||||
> - **P0-3 桥接层**:`remote_bridge.rs` 完整实装(`handle_remote_command`:121 + `MiniCommand`:83 + `from_payload`:96 + match 路由 10 条 + `route_list/load_messages` publish_event)+ **已 wire** `lib.rs:150-154` `on_command = Arc::new(...handle_remote_command...)` + `:171` `.connect(&relay_url,&device_id,&token,on_command.clone())`。非占位/设计态。`remote_bridge.rs:40-42` 的 `#![allow(dead_code)]`+「零调用方」注释写于联调前,现 lib.rs 已调(过时标注,待清)。
|
||||
|
||||
**属实补充项(用户提出 + 主代核源码确认,追加 todo)**:
|
||||
|
||||
- [x] P1-F-260622 [P1🟡·✅2026-06-23] — **消息操作缺失(复制 / regenerate UI 入口)**。`regenerate()`(`useAiChat.ts:520`)已实现但 chat 页无入口;复制消息功能全缺(桌面端有 hover 操作栏)。修:消息气泡加长按/点击操作(复制全文 / regenerate)。— `apps/df-miniapp/src/pages/chat/index.vue`
|
||||
- [x] P1-G-260622 [P1🟡·体验·✅2026-06-23] — **Markdown 渲染简陋**。`MdView.vue` 用 rich-text 无代码高亮 / 图片预览 / 链接拦截(MdView.vue 注释自认弱点);流式无节流(注释承认「长回复卡顿后续 P1 加 setTimeout 节流」)。修:评估 mp-html CLI local import `<node>` 子组件不渲染问题的修复,或换方案 + 流式 setTimeout 节流。— `apps/df-miniapp/src/components/MdView.vue`
|
||||
- [x] P2-D-260622 [P2·健壮·✅2026-06-23核验已修] — **断连期间操作静默丢失**。`wsClient.send()` 失败仅 console.warn + 返 false,但 `stop()/approve()/authorizeDir()` 忽略返回值 → 用户点「批准」UI 已乐观移除 pending 但命令未发出。修:这些方法检查 send 返回值,失败 toast 提示 + 不移除 pending。— `apps/df-miniapp/src/composables/useAiChat.ts`
|
||||
- [x] P2-E-260622 [P2·健壮·✅2026-06-23] — **审批乐观移除无失败回退**。approve() 即 splice pending,若 device 端 tool_call_id 已被桌面端处理致审批失败,miniapp 已移除 UI 误导用户以为成功。修:approve 失败(超时 / AiError 事件)回填 pending。— `useAiChat.ts`
|
||||
- [ ] P3-B-260622 [P3·真机前置] — **manifest appid 占位**。`PLACEHOLDER` 无法真机调试,需微信公众平台注册的真 appid。— `apps/df-miniapp/src/manifest.json`
|
||||
- [x] P3-C-260622 [P3·清理·✅2026-06-23 核验已清] — **mp-html 死依赖残留**。package.json 仍引 `mp-html@^2.5.2` 但 MdView 已改 rich-text 弃用。确认不回退 mp-html 则移除依赖(当前 `src/components/mp-html/` 预留保留,依 [[dead-code-reserve-keep]] 待回退决策)。— `apps/df-miniapp/package.json` — **核验:package.json 已无 mp-html + grep 全 src 零引用,已清(陈旧 todo)**。
|
||||
- [x] P3-D-260622 [P3·体验·✅2026-06-23] — **token 用量 UI 缺失**。`tokenUsage` 状态有但页面未展示。— `apps/df-miniapp/src/pages/chat/index.vue`
|
||||
|
||||
### ✅ 2026-06-23 miniapp 全功能审查加固(workflow wll7qabgr + 主代串行)
|
||||
|
||||
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程;2 区域限流主代补审)。**自主实施**(逐批 vue-tsc 0 + build DONE 核验):
|
||||
> - **F1** `AiConvStateChanged` idle/error 终态漏 clearWatchdog → 补 clearWatchdog(兑现注释承诺,防 timer 泄漏假超时)
|
||||
> - **F2** regenerate() 无 generating 守卫 → 加守卫 + toast(防 send 静默 return 无反馈)
|
||||
> - **F3** flushCurrentText 启发式回填错位 → 引入 currentAssistantMsgId 按 id 精确回填(治 retry/error/compressed 气泡穿插污染)
|
||||
> - **F9** stop() 无终态兜底 + **chat 页无停止按钮** → stopping 标志乐观复位 + 忽略迟到 delta + 生成中显「停止」按钮(原 generating 时 send 禁用且无停止入口,用户无法中断生成)
|
||||
> - **F21** 连接状态裸枚举字符串 → 中文文案 + 断开/重连中点状态栏手动重连
|
||||
> - switch 加 default warn / ws.ts 二进制帧 warn / scheduleReconnect 加 MAX_RECONNECT_ATTEMPTS=20 + 手动重连复位计数
|
||||
> - 删 pages/test 死页(uniapp alpha tap 排查探针,grep 零引用)
|
||||
>
|
||||
> **06-22 走查项核验销账**(源码已修,标 [x]):P2-B deviceOnline 复位 ✅ / P2-D 断连操作 send 返回值检查 ✅ / P1-B 握手结构化解析 ✅
|
||||
>
|
||||
> **06-22 项部分缓解**(alternative approach):P1-A 心跳 pong 闭环 → 当前用「任意入站消息」活性检测(HEARTBEAT_DEAD_FACTOR)替代 pong 往返,半开死连接已有兜底;pong 精确闭环见 MINIDEC-260623-06 ⏸️ / P1-C 冷启动恢复 → active conv id 已持久化 + 握手发 list_conversations;冷启动自动 load_messages 待核
|
||||
>
|
||||
> **剩余开放项 → 待决策**(见 [待决策.md](./待决策.md) df-miniapp 组):MINIDEC-260623-01 配置层(=旧 P1-D)/ 02 配对 / 06 心跳历史协议 / 07 regenerate+备份组件漂移。(03 重连续流 / 04 审批双源 / 05 会话页重命名 已实施,见下)。06-22 走查剩余 [ ]:P1-A(缓解)/P1-E(MaxRounds UI)/P1-F(复制+regenerate UI)/P1-G(Markdown 渲染)/P2-C(注释)/P2-E(审批回填)/P3-A/B/C/D。
|
||||
|
||||
### ✅ 2026-06-23 miniapp 用户需求批(req1/2/3 · 用户决策后实施)
|
||||
|
||||
> 用户 3 要求:(1) 会话重命名对齐桌面端 (2) 断网消息不丢失+从远端拉完整 (3) 审批断网重连恢复卡片状态。逐批 cargo check 0 + vue-tsc 0 + build DONE 核验。
|
||||
> - **req1 会话重命名**:remote_bridge `rename_conversation` 路由(调 ai_conversation_rename + 推 AiConversationList 刷新)+ useAiChat `renameConversation`(乐观本地更新)+ conversations/index.vue 长按 `uni.showModal` 编辑。
|
||||
> - **req2 断网不丢消息**:useAiChat `syncOnConnect` —— ws 'connected' 触发发 load_messages 拉当前会话完整历史(断网 missed 消息恢复 + 兼修冷启动空白 P1-C-260622)。
|
||||
> - **req3 审批重连恢复**:remote_bridge `sync_pending` 路由(读 AiSession.pending_approvals 按 conv 过滤,重发 AiApprovalRequired/AiDirAuthRequired)+ useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建 + handleEvent 同 id 去重防竞态。**审批卡改从 pendingApprovals 独立面板渲染**(与 messages 解耦,避重连 load_messages 替换 messages 的竞态;落地 MINIDEC-04 单一渲染源决策 a),内联工具卡保留状态徽标/参数/结果。
|
||||
> - **P1-C-260622 冷启动恢复** 顺带修复(syncOnConnect 初始连接也拉历史 + active conv id 已持久化)。
|
||||
|
||||
### ✅ 2026-06-23 miniapp 🟡 收尾批(P1-E/F + P2-C/A + P3-D/C · 自主推进)
|
||||
|
||||
> 用户定「自用」后低风险收尾项一次性做完。逐项 vue-tsc 0 + build DONE 核验。
|
||||
> - **P1-E MaxRounds 继续/停止**:useAiChat `maxRoundsActive` ref(AiMaxRoundsReached 置 true,continueLoop/stopLoop/send/switch/new 清)+ chat 「已达最大轮次」面板(继续一轮/停止按钮)。
|
||||
> - **P1-F 重发 + 复制**:chat 输入栏「重发」按钮(调 regenerate,非生成中且有消息时显)+ 消息 @longpress 复制全文(uni.setClipboardData)。
|
||||
> - **P3-D token 用量**:顶栏显 🪙 total(ai.tokenUsage)。
|
||||
> - **P2-C 注释陈旧**:relay.ts 路由表 12→14(+ rename/sync_pending)+ remote_bridge.rs 表头补两行。
|
||||
> - **P2-A messages 内存上限**:useAiChat watch trim MAX_STORE_MESSAGES=500(渲染另有 visibleMessages slice 200)。
|
||||
> - **P3-C mp-html 死依赖**:核验 package.json 已无 + grep 全 src 零引用(陈旧 todo,无需改)。
|
||||
> - **MINIDEC-07** 随 P1-F 选 a(regenerate 加 UI 不删)+ 备份组件保留预留,已落。
|
||||
>
|
||||
> **剩余 [ ](非阻塞)**:P1-A 心跳 pong(已缓解=MINIDEC-06 暂缓)/ P3-B manifest appid(真机前置,需注册)。(P1-G/P2-E/P3-A 见下 polish 批已落)
|
||||
|
||||
### ✅ 2026-06-23 miniapp polish 批(workflow wll377z2c 设计 → 串行实施)
|
||||
|
||||
> workflow 3 agent 并行设计(无限流)→ 汇总排序 → 主代串行实施,逐项 vue-tsc 0 + build DONE。
|
||||
> - **P2-E 审批失败回填**(low):useAiChat `lastApprovedTc` ref —— approve(approved=true) 记录,device 返 AiError 回填 pendingApprovals + toast「审批操作未生效,请重试」让用户重试;AiApprovalResult/AiToolCallCompleted 确认 → 清;切会话/新建/断连 → 清。
|
||||
> - **P1-G Markdown 渲染**(medium):新建 `utils/mdRenderer.ts` —— marked HTML 后处理注入 inline style(pre 深底等宽/code 橙色/a 蓝下划线/table border),规避 marked renderer v18 签名风险(rich-text 不认 class)。chat/index.vue renderMd + MdView.vue 接入。**链接跳转 rich-text 不可行**(<a> 节点事件屏蔽)降级展示+长按复制,跳转留 mp-html 后续。
|
||||
> - **P3-A textarea 多行**(high):input→textarea auto-height(max-height 120px + .bar flex-end 对齐)。⚠️ **原生层级遮联想浮层风险待用户实测**(textarea 原生组件渲染在 WebView 之上,可能遮盖 .mention-pop);若遮挡严重,fallback 联想浮层改 cover-view(单独任务,本次未做)。
|
||||
>
|
||||
> **miniapp 自用阶段功能全部闭环**。剩 P1-A(暂缓)/P3-B(appid 真机前置,需你注册)。
|
||||
|
||||
**与主走查重复(用户项 = 已登记,跳过)**:用户 P1-5(配置硬编码)= P1-D / P1-6(MaxRounds UI)= P1-E / P2-11(deviceOnline 不复位)= P2-B / P2-9(R1 并发防护)= `remote_bridge.rs:27-33` `check_generating_reject` **已实现**(send_message 路由前置 generating 检查)。
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow)
|
||||
|
||||
> 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。
|
||||
>
|
||||
> **BUG-260624-01 消息重叠/堆叠根治**(用户 2d89921a×3/d056b5ad×2 共 5 次报告):
|
||||
> - 根因:currentText 全局单例 + flushCurrentText 末尾盲写 + MessageList streamingBlocks(:69 实例级)跨轮/跨会话残留 + 渲染 isLastAi&¤tText 非 streaming 态也驱动。
|
||||
> - 修复(4 处协同,治本非补丁):① useAiEvents `flushCurrentText` 回填后自清 currentText(消除调用方清空顺序依赖)② MessageList currentText watch 归零清 streamingBlocks+lastStreamText(新轮)③ 1011 v-if 加 streaming(堵非 streaming 残留)④ activeConversationId watch 切会话清 streamingBlocks+lastStreamText+cancelAnimationFrame(治 switchConversation/restoreGeneratingState 残留根因,论证完整性高优)。
|
||||
>
|
||||
> **BUG-260624-02 授权弹窗卡死根治**(用户 8e4944b7/b1f644a7「没审批直接死了」):
|
||||
> - 根因:统一审批开关 `df-ai-unified-approval` 默认开 → path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(group-hidden display:none)→ 审批卡不可见 → 5min 超时(APPROVAL_TIMEOUT_MS)静默 deny → 误显「用户拒绝」卡死。ToolCard 根 :5 动态拼 `ai-tool-card--'+tc.status` 已挂 pending_approval class(scrollToFirstPending 可命中)。
|
||||
> - 修复:useAiEvents AiDirAuthRequired/AiApprovalRequired case 内 `emit('ai-pending-arrived')`;ToolCardList onMounted listen → scrollToFirstPending(自动展开折叠组+scroll,原仅手动徽标触发)+ 卸载竞态 disposed 标志(论证回归中优)。
|
||||
>
|
||||
> **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。
|
||||
>
|
||||
> **剩余低优 [ ](非阻断)**:
|
||||
> - [ ] BUG-260624-01 残留:scrollToFirstPending 去抖(同 toolCallId 不重复 scroll,防多 pending 连发滚动打断)+ 全实例广播过滤(props.toolCalls.some 跳过无 pending 实例,大列表性能)
|
||||
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到)
|
||||
> - [ ] MessageList:503 badge scrollToFirstPending return 过早(既有 bug,遍历实例应找含卡的那个,不首个 return)
|
||||
> - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md)
|
||||
|
||||
|
||||
118
docs/待决策.md
118
docs/待决策.md
@@ -50,6 +50,17 @@
|
||||
- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
|
||||
- **状态**:🟡 待用户实测
|
||||
|
||||
#### S-260623-01 「自托管」含义澄清
|
||||
- **背景**:用户要求设置支持「自主执行(含高危)+ 自托管」。**自主执行含高危已实现** —— `autoExecuteMode` 三档 `low`/`medium`/`all`(AdvancedSection.vue F-#97, 2026-06-22),`all` = 完全 AI 接管含高危(delete/purge/run_command),选时二次确认。设置 → 高级 → AI 自动执行范围。但「自托管」grep 全项目无匹配,含义未明确。
|
||||
- **待用户澄清**:「自托管」指什么?
|
||||
- **选项**:
|
||||
- a: 自托管 AI 模型 endpoint(用户配自己的模型 API/本地模型,非默认 provider)
|
||||
- b: 自管执行策略(= autoExecuteMode 已覆盖,无需另做)
|
||||
- c: 自托管后端服务(用户自部署 df-relay/df-tunnel,非默认 `u-work.1216.top`)
|
||||
- d: 其他(用户补充)
|
||||
- **关联**:task105 / memory(设置自主执行已实现)
|
||||
- **状态**:✅ **自用阶段定案(2026-06-23)** —— 用户决策:小程序当前自用(开发阶段),分发是产品终态但留后续(过渡期靠改 device_id 实现多机)。故「自托管」现阶段 = 已实现的自主执行(autoExecuteMode all 含高危),无另义。分发阶段再做配置层(MINIDEC-01)+ 配对(MINIDEC-02)。
|
||||
|
||||
#### ~~B-260617-11 tauri.conf.json 打包目标收窄确认~~
|
||||
- **状态**:✅ 已决(2026-06-18) — 已迁入归档。决策:A 临时本地构建,要求高速。提交前 revert 为 `"all"`。
|
||||
|
||||
@@ -172,6 +183,92 @@
|
||||
- **关联**:消息级溯源 P0(CR-10 ✅)/ P1(CR-12 ✅)/ 消息拆分存储设计文档
|
||||
- **状态**:🟡 待用户拍板(P2 是否现在做 + 策略 a/b)。大改建议用户知情后启动。
|
||||
|
||||
#### BUG-260623-03 审批 pending 无超时兜底(aichat 卡死·架构)
|
||||
- **背景**:实测会话 4ae73423 末尾 advance_task 触发审批 `__PENDING__` 后,`agentic/mod.rs:1464` loop return,恢复唯一依赖用户主动 ai_approve。全库无审批超时定时器(cache.rs:31 占位标记无时间戳)。用户离开/忘记 → generating=true 永久挂起,会话卡死,用户须手动重发(且该会话 token 已大,重发易触上限)。属 aichat 中断B,非 generating 状态机卡死(generating 已收敛 GeneratingGuard)。
|
||||
- **决策点**:审批 pending 超时兜底怎么实现?
|
||||
- **选项**:
|
||||
- a: 后端 per-conv 定时器(到期自动 reject + emit 超时,触及 AiSession 单例 + 定时器机制,最彻底)
|
||||
- b: 前端倒计时提示(用户可见到期,后端不变,最小改动)
|
||||
- c: 无超时(现状,靠用户手动)
|
||||
- **推荐**:**b 前端倒计时**(最小改动不触 AiSession;用户可见主动处理)。a 后端定时器最彻底但触及单例大改,归 [[devflow-async-approval-concept]]
|
||||
- **关联**:memory devflow-async-approval-concept / docs/05-代码审查/aichat-会话实测分析-2026-06-22.md(CR-260622-01 修正)
|
||||
- **状态**:🟡 待决策
|
||||
|
||||
### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审)
|
||||
|
||||
> 6 区域并行审查(chat/会话页/useAiChat/连接层/组件/工程)。已**自主实施**:F1 看门狗 clearWatchdog 兜底 / F2 regenerate 守卫 / F3 flushCurrentText id 精确回填 / F9 stop 终态兜底 + 停止按钮(原无停止入口) / F21 连接状态中文文案 + 手动重连 / switch default 分支 / 二进制帧 warn / scheduleReconnect maxAttempts / 删 test 死页。以下为需决策项。
|
||||
|
||||
#### MINIDEC-260623-01 relay 配置层范围(F15·产品方向·分发卡点)
|
||||
- **背景**:config.ts relayHost/deviceId/token 全硬编码(setConfig 零调用方,storage 未接,无设置页)。当前填测试服 wss + 本机 device_id(联调期)。分发后用户无法改连自托管中继 → 连不上。
|
||||
- **决策点**:配置层做到什么程度?
|
||||
- **选项**:
|
||||
- a: 完整设置页(表单 + setConfig 接 storage 持久化 + getConfig 回退),用户可改 relay/device/token
|
||||
- b: 仅接 storage 持久化(setConfig 真生效,无 UI,靠代码/build env 注入)
|
||||
- c: MVP 接受硬编码(仅自用联调),设置页立项后续
|
||||
- **推荐**:**b 至少接 storage**(成本极小让配置真生效);a 设置页 UI 视分发排期。依赖 S-260623-01「自托管」含义(c 自托管后端 → 必须 a)。
|
||||
- **关联**:S-260623-01 / memory cross-end-rust-backend
|
||||
- **状态**:⏸️ **暂缓(2026-06-23)** —— 用户决策:当前自用,硬编码够;分发阶段(产品终态)再做设置页。过渡期多机靠改 device_id。
|
||||
|
||||
#### MINIDEC-260623-02 配对绑定流程(F16·跨端大改)
|
||||
- **背景**:ControlMessage.pair 类型已定义但从不发送,deviceId/token 靠用户手抄(查桌面端 KV)。无二维码配对/绑定校验链路。
|
||||
- **决策点**:配对流程是否本轮做?
|
||||
- **选项**:
|
||||
- a: 完整配对(桌面端生成 QR → relay 校验绑定 → miniapp 扫码获 device_id/token)
|
||||
- b: MVP 过渡(设置页手填 + UI 提示去哪找 device_id/token)
|
||||
- c: 不做(手抄够用)
|
||||
- **推荐**:**⏸️ b MVP 过渡**(a 跨端协作工作量大,依赖 MINIDEC-260623-01 设置页先落)
|
||||
- **关联**:MINIDEC-260623-01
|
||||
- **用户已定未来愿景(2026-06-23)**:分发阶段做完整配对授权,三步:① 设备端弹二维码 ② 小程序扫码识别并授权,双方来回点击确认 ③ 授权管理(设定授权的权限范围等)。本轮不做,留专项。
|
||||
- **状态**:⏸️ **暂缓(2026-06-23)** —— 当前自用靠手抄 device_id;分发阶段按上述愿景做完整 QR 配对 + 权限范围管理(跨端 df-miniapp/df-relay/桌面端协同,专项立项)。
|
||||
|
||||
#### MINIDEC-260623-03 WS 重连续流策略(F10·行为差异·需拍板)
|
||||
- **背景**:watchdog 在 WS 重连后不重启。重连期间 device 续推 AiTextDelta 会丢(断连窗口)或半截文本错位。当前断连 onStatus 已 reset generating + clearWatchdog(连接断肯定停),但重连后续流未处理。
|
||||
- **决策点**:重连后续流策略?
|
||||
- **选项**:
|
||||
- a: onStatus 'connected' 主动发 load_messages 重新同步整个会话(最稳,但重置视图)
|
||||
- b: 收到 AiTextDelta 时若 !generating 隐式恢复 + 补 assistant 占位(最平滑,有幽灵续流风险)
|
||||
- c: 不处理(接受断连窗口该轮丢失)
|
||||
- **推荐**:**c 现状**(MVP 断连窗口丢失可接受,a/b 复杂度高)。重度依赖移动端弱网再升级 a。
|
||||
- **状态**:✅ **已实施 a**(2026-06-23)—— 用户决策「断网不丢消息 + 从远端拉完整」。useAiChat syncOnConnect:ws 'connected' → 发 load_messages 拉完整历史(断网期间 missed 消息恢复 + 兼修冷启动空白 P1-C)。cargo check 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-04 审批双源状态分裂统一(F4/F5/F6/F13·渲染源决策)
|
||||
- **背景**:审批状态双写——pendingApprovals 数组 + messages[].toolCalls.status。AiApprovalRequired 双写为源头,AiError/switchConversation 只清 pendingApprovals 一侧(messages 内 tc 留陈旧 pending_approval)→ 可能渲染陈旧审批按钮,点 approve 命中已失效 tool_call。chat 页按 m.toolCalls 内联渲染审批按钮,pendingApprovals 仅顶部徽标计数。
|
||||
- **决策点**:统一单一渲染源?
|
||||
- **选项**:
|
||||
- a: 以 pendingApprovals 为准(messages tc 仅显示 status 不渲染按钮)—— 改 chat 页渲染逻辑(行为变更)
|
||||
- b: switchConversation/AiError 清 pendingApprovals 时同步遍历 messages 把 pending_approval 标 rejected —— 改 useAiChat 状态清理(纯后端态,UI 不变)
|
||||
- **推荐**:**b 同步清理**(最小行为变更,治陈旧按钮根因)。a 渲染源统一更彻底但 UI 改动大。
|
||||
- **关联**:BUG-260623-03(审批超时,后端侧)
|
||||
- **状态**:✅ **已实施 a 单一渲染源**(2026-06-23)—— 用户决策「审批断网重连恢复卡片状态」。审批卡改从 pendingApprovals 独立面板渲染(与 messages 解耦,避重连 load_messages 替换 messages 的竞态);remote_bridge 加 sync_pending 路由(读 AiSession.pending_approvals 按 conv 过滤重发 AiApprovalRequired/AiDirAuthRequired);useAiChat onStatus 'connected' 清 pendingApprovals + 发 sync_pending 重建;handleEvent 加同 id 去重防竞态重复。内联工具卡保留状态徽标/参数/结果(按钮移面板)。cargo 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-05 会话页管理缺口(删除/重命名·需后端命令)
|
||||
- **背景**:conversations/index.vue 仅列表/切换/新建/下拉刷新。无删除/重命名(需后端 delete_conversation/rename_conversation 命令 + remote_bridge 路由,当前无)。
|
||||
- **决策点**:miniapp 是否需要会话管理?
|
||||
- **选项**:
|
||||
- a: 本轮加(后端命令 + bridge 路由 + 会话页长按菜单)
|
||||
- b: 暂不做(用户回桌面端管理,MVP 仅查看/切换)
|
||||
- **推荐**:**⏸️ b 暂不做**(miniapp 定位轻量操作终端,会话管理桌面端足够)
|
||||
- **状态**:✅ **重命名已实施 / 删除暂缓**(2026-06-23)—— 用户决策「小程序对齐桌面端能改会话名」。remote_bridge 加 rename_conversation 路由(调 ai_conversation_rename + 推 AiConversationList 刷新);useAiChat renameConversation 方法(乐观本地更新);conversations/index.vue 长按会话 uni.showModal 编辑。删除会话暂不做(桌面端管理)。cargo 0 + vue-tsc 0 + build DONE。
|
||||
|
||||
#### MINIDEC-260623-06 心跳/历史替换协议(F8/F20·跨模块·低优先)
|
||||
- **背景**:(F8)心跳活性检测依赖「任意入站消息」,低活跃场景每 ~60s 误判半开死连接触发无谓重连(relay 无 pong);(F20)AiMessageHistory 整体替换会丢弃正在进行的乐观气泡(device 主动推历史路径难区分 load 响应)。
|
||||
- **决策点**:是否本轮改 relay 协议?
|
||||
- **选项**:
|
||||
- a: relay 加 pong + 历史事件加 load_id/sync_source flag(跨模块改 df-relay + 后端 events)
|
||||
- b: 前端缓解(放宽 DEAD_FACTOR / 「仅本地空才替换历史」启发式,语义脆弱)
|
||||
- c: 暂缓(MVP 低频空闲场景可忍)
|
||||
- **推荐**:**⏸️ c 暂缓**(跨模块改动 ROI 低)
|
||||
- **状态**:⏸️ 暂缓
|
||||
|
||||
#### MINIDEC-260623-07 regenerate 零调用方 + 备份组件漂移(P3 收尾)
|
||||
- **背景**:(1) useAiChat.regenerate() 零调用方(全死,已加 generating 守卫防御);(2) MdView.vue/MentionInput.vue 因绕工具组件解析 bug 被 chat 页内联,源文件保留备用但已与内联实现漂移(MdView 无 mdCache、MentionInput 是 MVP 占位)——「拆回」时会引入旧实现。
|
||||
- **决策点**:regenerate 加 UI(重发按钮)or 删?备份组件同步/删/保留?
|
||||
- **选项**:
|
||||
- a: regenerate 加重发按钮 + 备份组件同步内联实现(或删)
|
||||
- b: regenerate 删减负 + 备份组件保留标注释(预留工具 bug 修复后拆回)
|
||||
- **推荐**:**b**(regenerate 删减负,备份保留预留意图)。或 a 视移动端重发需求。
|
||||
- **状态**:✅ **随 P1-F 选 a**(2026-06-23)—— 🟡 收尾批给 regenerate 加「重发」UI 入口(对齐桌面端),故保留函数不再删;备份组件(MdView/MentionInput)保留预留注释(绕工具解析 bug,未来拆回)。
|
||||
|
||||
---
|
||||
|
||||
## workspace_root 分发适配(编译期 CARGO_MANIFEST_DIR 写死,跨机器/跨平台失效)
|
||||
@@ -200,6 +297,8 @@
|
||||
**关联**:F-260619-03 去固定根(已删 is_authorized 硬编码放行)/ projects.path 自动授权(reload_allowed_dirs)/ memory [[devflow-project-path-binding]]
|
||||
**状态**:🟡 待决策(workspace_root 运行期动态方案 a/b/c/d)。**分发前必须解决**(否则分发后 AI 文件工具全部失效)。
|
||||
|
||||
**2026-06-23 进展(BUG-260623-01)**:实测会话 6acb7f9b 暴露 write_file/append_file/rename_file 父目录校验**误用 workspace_root**(tool_registry.rs:1112/1418/1555 原用 `starts_with(workspace_root())`)→ 用户授权的其他项目目录(ai-news 等)parent 不 starts_with devflow 源码目录 → 误拒「禁止在项目目录之外创建目录」(用户授权了仍拒)。**已修**:三处改用 `allowed_dirs.is_authorized(parent)` 脱离 workspace_root。**仍写死残留**(待本决策 a/b/c/d 根治):delete_file:1482 trash 目录 / resolve_workspace_path_impl:380 相对路径 join / run_command:1791 working_dir 默认。
|
||||
|
||||
---
|
||||
|
||||
## F-260619-03 路径授权政策:工程内默认免授权(用户 2026-06-20 指令)
|
||||
@@ -221,6 +320,25 @@
|
||||
|
||||
---
|
||||
|
||||
## F-09 streaming/currentText 改 per-conv(消息重叠/多会话串扰根治)
|
||||
|
||||
**背景**:BUG-260624-01 消息重叠核心修复后,论证 workflow 边界角度指出残留根因——`streaming`/`currentText` 是 store 全局单例(非 per-conv),F-09 多会话并发下 A 后台生成(streaming=true)+ 切 B 会话,B 末条 AI 气泡命中 `isLastAi&&streaming&¤tText` 会渲 A 残留 currentText。当前修复(flushCurrentText 自清 + activeConversationId watch 清 streamingBlocks)已大幅收窄残留窗口,但单例根因未除。
|
||||
|
||||
**决策点**:是否立项 per-conv 化(streaming/currentText/streamingBlocks 改 Map<convId,...>)?
|
||||
- **a 立项根治**:store 改 per-conv Map,所有读写点改。大改(涉 ai.ts store + useAiEvents/useAiStream/useAiConversations/MessageList 多处),对齐 [[aichat-arch-extensibility]] AiSession 单例未动 + [[fe-arch-tech-debt]] status 无 union。
|
||||
- **b 暂缓(当前缓解够用)**:自清 + activeConv watch 已覆盖主路径(新轮/切会话/结束清),残留仅极端并发场景(A 后台 streaming + 切 B)。等 B 路线多会话并发([[aichat-b-route-parallel-multiround]])正式推进时一并 per-conv。
|
||||
|
||||
**多角度论证(自主决策依据)**:
|
||||
- 根治性:a 一次性消除单例串扰族 bug;b 缓解主路径,极端并发残留。
|
||||
- 改动面:a 大(store + 5 composable + MessageList),回归风险高,需全链路测;b 零额外改动。
|
||||
- 时机:b 与 B 路线多会话并发同批做更合理(那时 per-conv 是前置),现在单独做与 B 路线重复拆改。
|
||||
- 当前用户场景:自用单会话为主,多会话并发(A 后台 + 切 B)非高频。
|
||||
|
||||
**倾向**:**⏸️ b 暂缓**(当前修复缓解有效 + per-conv 是 B 路线大改 + 与多会话并发同批更合理)。
|
||||
**状态**:🟡 待拍板(若用户多会话并发场景频繁 → a 立项;当前单会话为主 → b 暂缓随 B 路线)。
|
||||
|
||||
---
|
||||
|
||||
## 已决归档
|
||||
|
||||
按月归档(随时间增长追加月份文件,防主文件膨胀):
|
||||
|
||||
19
docs/待审查.md
19
docs/待审查.md
@@ -23,6 +23,25 @@
|
||||
|
||||
## 当前队列
|
||||
|
||||
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— 🟡 待审
|
||||
|
||||
- **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。
|
||||
- **维度**:(a) 独立核验 db 实测数据(token 比/重复 tool 结果/system 污染/时序);(b) 核验源码根因(报告 finding 均为"假设"待证)。
|
||||
- **核验方式**:只读 DB(`mode=ro`)+ grep/read 源码,**不信报告结论**(防上下文污染,[[code-review-anti-contamination]])。
|
||||
- **待审点(8)**:
|
||||
- P0-1 压缩标记 `compressed` 未真降 prompt(1.32M)— 核 compress 模块是否替换为摘要还是仅打标记
|
||||
- P0-2 `patch_file`/`read_file` 部分路径裸吐文件全文(`raw 6204c`)非 JSON — 核 `tool_registry.rs` 返回序列化分支
|
||||
- P0-3 重复 tool 结果(seq 11-14 四份/69-71/83-85 三份同长度同秒)— 核 `ai_messages` 落库去重 / 流式 patch 合并
|
||||
- P1-1 过早宣称完成 3 次被推翻,无 checklist 自检 — agent 行为层
|
||||
- P1-2 末尾 #82→#85 tool 全返回后无 assistant 回复(中断)— 核 generating 复位/`try_continue`
|
||||
- P1-3 三独立文件(mysql/redis/linux)整理本可并行却严格串行,无 fan-out 调度 — 印证 [[aichat-b-route-parallel-multiround]]
|
||||
- P2-1 seq 0 system 消息内容污染 + 时序错乱(晚 seq 1 十小时)— 核 system 注入 + timestamp
|
||||
- P2-2 同文件反复 read(mysql/linux 各 4×)无缓存
|
||||
- **最高杠杆**:P0-2(工具返回改 diff/changed 而非全文)→ prompt 降一个量级。
|
||||
- **关联**:[[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[aichat-b-route-parallel-multiround]] [[aichat-decision-capability]]
|
||||
|
||||
---
|
||||
|
||||
### CR-260620-04 Agent 最大轮次支持「不限」(working tree 未提交:0=不限 约定·config.rs clamp 0-50 + mod.rs effective_max 0→usize::MAX + GeneralPanel checkbox + App.vue clamp + i18n labelUnlimited) — ✅ 已审(PASS·⚪2 WATCH·巡检 2026-06-20 独立 grep/read 核验控制流)
|
||||
|
||||
- **范围**:新功能。约定 0=不限:后端 config.rs:69 clamp(1,50)→(0,50);mod.rs:542 loop 上界算 effective_max(0→usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出,达上限暂停 AiMaxRoundsReached 不触发);前端 GeneralPanel 加「不限」checkbox(绑 agentMaxIterations===0)+ input :disabled + clamp 支持 0 + computed unlimited;App.vue:107 clamp 跟进(iter===0?0:...);i18n zh/en desc 加 0=不限 + labelUnlimited。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"build:dev": "cargo tauri build --debug"
|
||||
"build:dev": "cd src-tauri && cargo build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
|
||||
@@ -36,7 +36,12 @@ df-ai = { path = "../crates/df-ai" }
|
||||
df-ideas = { path = "../crates/df-ideas" }
|
||||
df-project = { path = "../crates/df-project" }
|
||||
df-mcp = { path = "../crates/df-mcp" }
|
||||
# Phase3 跨端隧道:桌面端主动出站连 df-relay(/ws/device),透传 ai_event_bus → miniapp
|
||||
# + 转发 miniapp 下行指令 → remote_bridge 路由 Tauri command。穿 NAT 无需端口映射。
|
||||
df-tunnel = { path = "../crates/df-tunnel" }
|
||||
futures = "0.3"
|
||||
# Phase3 tunnel device_id:首次启动生成 UUID v4 落 settings KV 持久化(relay 按 device_id 路由配对)
|
||||
uuid = { workspace = true }
|
||||
# write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等)
|
||||
base64 = "0.22"
|
||||
# validate_path URL 解码:防 %2e%2e 等 URL 编码绕过路径遍历检查(BUG-260617-03)
|
||||
@@ -52,6 +57,15 @@ keyring = { workspace = true }
|
||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
|
||||
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
|
||||
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }
|
||||
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
|
||||
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
|
||||
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
|
||||
# Phase1 范围:Rust(.rs)/TypeScript(.ts/.tsx)/JavaScript(.js/.jsx)/Vue(.vue 借 TS 切 script 段),3 grammar 覆盖 4 类。
|
||||
# 兼容:tree-sitter 0.25 主 crate 与 grammar crate 配对(各 grammar 声明 tree-sitter 依赖,0.25 系广泛兼容)。
|
||||
tree-sitter = "0.25"
|
||||
tree-sitter-rust = "0.24.2"
|
||||
tree-sitter-typescript = "0.23.2"
|
||||
tree-sitter-javascript = "0.25.0"
|
||||
|
||||
# 阶段3a/3b 统一审批模型开关(path_auth 审批链扎实重构 plan 阶段3)。
|
||||
# 3a 后端合并(单 HashMap + kind 字段)为编译期结构性变更,本 flag 用于文档标记 +
|
||||
|
||||
468
src-tauri/src/commands/ai/agentic/conv_state.rs
Normal file
468
src-tauri/src/commands/ai/agentic/conv_state.rs
Normal file
@@ -0,0 +1,468 @@
|
||||
//! L2 统一状态机 — `ConvState` enum(单一真相源)+ 合法转换守卫。
|
||||
//!
|
||||
//! 来源:`generating状态机加固-2026-06-15.md` §3(轻量状态机决策)+ `aichat体验与
|
||||
//! agent能力系统化重构-2026-06-21.md` §3(L2 统一状态机)。
|
||||
//!
|
||||
//! # 背景(为什么单独建这个 enum)
|
||||
//!
|
||||
//! 旧实现会话状态由三个标志组合隐式表达(`generating: bool` + `stop_flag: AtomicBool`
|
||||
//! + `pending_approvals` HashMap),判别逻辑散落 `try_continue_agent_loop` /
|
||||
//! `ai_chat_stop` / `ai_conversation_create` 三处(写侧散布 + 读侧散布双根因,见
|
||||
//! generating状态机加固-2026-06-15.md §2.3)。本模块提供**显式的、单一真相的**状态枚举:
|
||||
//!
|
||||
//! - **写侧收敛**:`ConvState` 仅经 [`ConvState::transition_to`] 守卫方法迁移,非法转换
|
||||
//! 直接拒绝(`Err(InvalidTransition)`),调用方无法绕过守卫写入非法组合。
|
||||
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
|
||||
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
|
||||
//!
|
||||
//! # 渐进第一步(本批范围)
|
||||
//!
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等
|
||||
//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState`
|
||||
//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html)
|
||||
//! 默认 on,off 降级纯旧 bool 行为(可回退)。
|
||||
//!
|
||||
//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到
|
||||
//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 开关
|
||||
// ============================================================
|
||||
|
||||
/// L2 状态机总开关(默认 on)。
|
||||
///
|
||||
/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html)
|
||||
/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`),
|
||||
/// 不影响语义(状态机层失败不阻断核心复位)。
|
||||
/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时
|
||||
/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。
|
||||
///
|
||||
/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。
|
||||
pub const CONV_STATE_ENABLED: bool = true;
|
||||
|
||||
// ============================================================
|
||||
// ConvState enum
|
||||
// ============================================================
|
||||
|
||||
/// 对话生命周期状态(单一真相源,5 态)。
|
||||
///
|
||||
/// 设计取舍(对齐 generating状态机加固-2026-06-15.md §3.2 「轻量状态机,不引入框架」):
|
||||
///
|
||||
/// - **不引入状态机框架**(codegen / FSM 库):5 态 7 边转换简单,枚举 + 显式 `transition_to`
|
||||
/// 守卫即可表达,过度封装得不偿失。
|
||||
/// - **派生关系明确**:`Compressed` 是 `Generating`/`Idle` 期间的瞬时派生态(压缩跑完
|
||||
/// 回到原态),非终端态;`Error` 可经用户「重试」回到 `Idle` 再 `Generating`。
|
||||
/// - **与读视图 [`SessionState`](../enum.SessionState.html) 区分**:`SessionState`
|
||||
/// (Streaming/AwaitingApproval/Idle)是面向「是否有审批挂起」的只读视图(读侧收敛①),
|
||||
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
|
||||
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
|
||||
///
|
||||
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 预留,避免后续改动序列化兼容性)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConvState {
|
||||
/// 空闲:无 agentic loop 活跃,可接新请求。
|
||||
///
|
||||
/// 终态目标:所有正常退出路径(完成 / 停止完成 / 错误恢复)的归处。
|
||||
Idle,
|
||||
/// 生成中:agentic loop 流式生成 + 工具执行活跃中(`generating=true`)。
|
||||
///
|
||||
/// 审批挂起期间 `ConvState` 仍为 `Generating`(审批是 loop 内的暂停点,非独立状态)。
|
||||
Generating,
|
||||
/// 停止中:用户点了停止(`stop_flag` 已置位),loop 检测中或正在收尾退出。
|
||||
///
|
||||
/// 瞬态:loop 检测到 `stop_flag` 后迁移到 `Idle`(完成收尾),或停止过程出错到 `Error`。
|
||||
Stopping,
|
||||
/// 错误态:生成过程出错(provider 失败 / Fatal / 断路器熔断等),loop 已退出。
|
||||
///
|
||||
/// 非终态:用户可「重试」从 `Error` → `Idle` → `Generating` 重新发起,或继续空闲。
|
||||
Error,
|
||||
/// 压缩派生态:loop 期间触发上下文压缩(LLM 摘要),生成流程的瞬时派生。
|
||||
///
|
||||
/// 派生而非独立态:压缩完成(成功 / 失败兜底)后回到原 `Generating`(loop 内压缩)或
|
||||
/// `Idle`(手动压缩 IPC 触发)。前端可据此展示「压缩中」spinner(本批未接)。
|
||||
Compressed,
|
||||
}
|
||||
|
||||
impl Default for ConvState {
|
||||
/// 新会话默认 `Idle`(无 loop 活跃)。
|
||||
fn default() -> Self {
|
||||
ConvState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
impl ConvState {
|
||||
/// 尝试迁移到目标态。合法返回 `Ok(新态)`,非法拒绝返回 `Err(InvalidTransition)`。
|
||||
///
|
||||
/// 调用方持锁后经本方法迁移状态(写收敛):不允许直接写字段绕过守卫。
|
||||
/// 非法转换(如 `Idle → Stopping`,无活跃 loop 不可能停止)直接拒绝——调用方应视为
|
||||
/// 逻辑 bug 并记录 warn 日志,不静默忽略(状态机完整性优先)。
|
||||
///
|
||||
/// # 合法转换表(7 边)
|
||||
///
|
||||
/// | from | to | 触发场景 |
|
||||
/// |------|----|---------|
|
||||
/// | Idle | Generating | `run_agentic_loop` 入口,guard set |
|
||||
/// | Generating | Stopping | 用户点停止,置 `stop_flag` |
|
||||
/// | Generating | Idle | loop 正常收敛退出,guard reset |
|
||||
/// | Generating | Error | provider Fatal / 断路器熔断等 |
|
||||
/// | Generating | Compressed | loop 内自动压缩派生 |
|
||||
/// | Stopping | Idle | stop 收尾完成,guard reset |
|
||||
/// | Stopping | Error | 停止过程出错 |
|
||||
/// | Error | Idle | 用户重试前置位(准备再 `Generating`) |
|
||||
/// | Error | Generating | 直接从错误态重新生成(重试) |
|
||||
/// | Compressed | Generating | 压缩完成,loop 内继续 |
|
||||
/// | Compressed | Idle | 手动压缩 IPC 完成(无 loop 活跃) |
|
||||
///
|
||||
/// 自环(同态 → 同态)允许(幂等写,如 guard 多次 set 同态),返回 `Ok(self)`。
|
||||
pub fn transition_to(self, target: ConvState) -> Result<ConvState, InvalidTransition> {
|
||||
// 自环幂等:同态迁移直接通过(防调用方重复 set 报错)。
|
||||
if self == target {
|
||||
return Ok(target);
|
||||
}
|
||||
// 合法转换白名单(显式列举,非 `match _ => Ok` 兜底——新加态必须显式补边,
|
||||
// 编译器不会漏)。
|
||||
let valid = matches!(
|
||||
(self, target),
|
||||
// Idle 起步
|
||||
(ConvState::Idle, ConvState::Generating)
|
||||
// Generating 分流:停 / 完成 / 错误 / 压缩派生
|
||||
| (ConvState::Generating, ConvState::Stopping)
|
||||
| (ConvState::Generating, ConvState::Idle)
|
||||
| (ConvState::Generating, ConvState::Error)
|
||||
| (ConvState::Generating, ConvState::Compressed)
|
||||
// Stopping 收尾:完成 / 出错
|
||||
| (ConvState::Stopping, ConvState::Idle)
|
||||
| (ConvState::Stopping, ConvState::Error)
|
||||
// Error 恢复:重试前置位 / 直接重新生成
|
||||
| (ConvState::Error, ConvState::Idle)
|
||||
| (ConvState::Error, ConvState::Generating)
|
||||
// Compressed 派生回退:loop 继续 / 手动压缩完成
|
||||
| (ConvState::Compressed, ConvState::Generating)
|
||||
| (ConvState::Compressed, ConvState::Idle)
|
||||
);
|
||||
if valid {
|
||||
Ok(target)
|
||||
} else {
|
||||
Err(InvalidTransition { from: self, to: target })
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否处于活跃生成态(`Generating` / 压缩派生 `Compressed`)。
|
||||
///
|
||||
/// 读侧便利方法:压缩派生期间 loop 仍活跃(只是临时跑压缩 LLM),归「活跃」。
|
||||
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
|
||||
/// 故本方法返回 true——审批挂起不算「停止生成」。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。
|
||||
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
|
||||
/// 不再标 allow(dead_code)(有真实消费方)。
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, ConvState::Generating | ConvState::Compressed)
|
||||
}
|
||||
|
||||
/// 是否可接受新请求(非活跃、非停止中、非压缩派生)。
|
||||
///
|
||||
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
|
||||
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。
|
||||
#[allow(dead_code)]
|
||||
pub fn can_accept_request(self) -> bool {
|
||||
matches!(self, ConvState::Idle | ConvState::Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// InvalidTransition(非法转换错误)
|
||||
// ============================================================
|
||||
|
||||
/// 状态机非法转换错误。
|
||||
///
|
||||
/// 携带 `from` / `to` 供调用方记录诊断日志(不 panic:状态机完整性违反应记 warn +
|
||||
/// 兜底走旧 bool 行为,不阻断核心生成流程——对齐「机制优先,失败兜底」原则)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvalidTransition {
|
||||
pub from: ConvState,
|
||||
pub to: ConvState,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InvalidTransition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "ConvState 非法转换:{:?} → {:?}", self.from, self.to)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidTransition {}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(纯逻辑无 IO)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- 合法转换通过 ----
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_generating() {
|
||||
assert_eq!(
|
||||
ConvState::Idle.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_stopping() {
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Stopping),
|
||||
Ok(ConvState::Stopping)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_idle_normal_complete() {
|
||||
// loop 正常收敛退出
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_error_provider_fatal() {
|
||||
// provider Fatal / 断路器熔断
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Error),
|
||||
Ok(ConvState::Error)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generating_to_compressed_loop_auto_compress() {
|
||||
// loop 内自动压缩派生
|
||||
assert_eq!(
|
||||
ConvState::Generating.transition_to(ConvState::Compressed),
|
||||
Ok(ConvState::Compressed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_idle_complete() {
|
||||
// stop 收尾完成
|
||||
assert_eq!(
|
||||
ConvState::Stopping.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_error() {
|
||||
assert_eq!(
|
||||
ConvState::Stopping.transition_to(ConvState::Error),
|
||||
Ok(ConvState::Error)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_idle_retry_prep() {
|
||||
// 用户重试前置位
|
||||
assert_eq!(
|
||||
ConvState::Error.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_generating_retry() {
|
||||
// 直接从错误态重新生成
|
||||
assert_eq!(
|
||||
ConvState::Error.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_generating_loop_continue() {
|
||||
// 压缩完成 loop 内继续
|
||||
assert_eq!(
|
||||
ConvState::Compressed.transition_to(ConvState::Generating),
|
||||
Ok(ConvState::Generating)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_idle_manual_compress_done() {
|
||||
// 手动压缩 IPC 完成(无 loop 活跃)
|
||||
assert_eq!(
|
||||
ConvState::Compressed.transition_to(ConvState::Idle),
|
||||
Ok(ConvState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 自环幂等 ----
|
||||
|
||||
#[test]
|
||||
fn test_self_transition_idempotent() {
|
||||
// 同态迁移直接通过(防调用方重复 set 报错)
|
||||
for s in [
|
||||
ConvState::Idle,
|
||||
ConvState::Generating,
|
||||
ConvState::Stopping,
|
||||
ConvState::Error,
|
||||
ConvState::Compressed,
|
||||
] {
|
||||
assert_eq!(s.transition_to(s), Ok(s), "{:?} 自环应通过", s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 非法转换拒绝 ----
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_stopping_rejected() {
|
||||
// 无活跃 loop 不可能停止
|
||||
let err = ConvState::Idle
|
||||
.transition_to(ConvState::Stopping)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.from, ConvState::Idle);
|
||||
assert_eq!(err.to, ConvState::Stopping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_error_rejected() {
|
||||
// Idle 直接到 Error 无意义(错误必经活跃态产生)
|
||||
assert!(ConvState::Idle.transition_to(ConvState::Error).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_to_compressed_rejected() {
|
||||
// 无 loop 活跃不会触发压缩派生(手动压缩 IPC 不经状态机派生)
|
||||
assert!(ConvState::Idle.transition_to(ConvState::Compressed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_generating_rejected() {
|
||||
// 停止中不能直接回生成(须先回 Idle 再起)
|
||||
assert!(ConvState::Stopping
|
||||
.transition_to(ConvState::Generating)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopping_to_compressed_rejected() {
|
||||
assert!(ConvState::Stopping
|
||||
.transition_to(ConvState::Compressed)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_stopping_rejected() {
|
||||
// 错误态已停止,不能再停
|
||||
assert!(ConvState::Error.transition_to(ConvState::Stopping).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_to_compressed_rejected() {
|
||||
assert!(ConvState::Error.transition_to(ConvState::Compressed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_stopping_rejected() {
|
||||
// 压缩派生期间不直接到停止(压缩完成后由原态分流)
|
||||
assert!(ConvState::Compressed
|
||||
.transition_to(ConvState::Stopping)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compressed_to_error_rejected() {
|
||||
assert!(ConvState::Compressed
|
||||
.transition_to(ConvState::Error)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// ---- 便利方法 ----
|
||||
|
||||
#[test]
|
||||
fn test_is_active() {
|
||||
assert!(!ConvState::Idle.is_active());
|
||||
assert!(ConvState::Generating.is_active());
|
||||
assert!(!ConvState::Stopping.is_active());
|
||||
assert!(!ConvState::Error.is_active());
|
||||
// 压缩派生期间 loop 仍活跃(临时跑压缩 LLM)
|
||||
assert!(ConvState::Compressed.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_accept_request() {
|
||||
assert!(ConvState::Idle.can_accept_request());
|
||||
assert!(!ConvState::Generating.can_accept_request());
|
||||
assert!(!ConvState::Stopping.can_accept_request());
|
||||
// Error 态可接(用户重试即从 Error 起步)
|
||||
assert!(ConvState::Error.can_accept_request());
|
||||
assert!(!ConvState::Compressed.can_accept_request());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_idle() {
|
||||
assert_eq!(ConvState::default(), ConvState::Idle);
|
||||
}
|
||||
|
||||
// ---- 完整生命周期链 ----
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_normal_complete() {
|
||||
// 正常生命周期:Idle → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_with_stop() {
|
||||
// 停止生命周期:Idle → Generating → Stopping → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Stopping).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_with_compress() {
|
||||
// 压缩派生:Idle → Generating → Compressed → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Compressed).unwrap();
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_lifecycle_error_retry() {
|
||||
// 错误恢复:Idle → Generating → Error → Generating → Idle
|
||||
let s = ConvState::Idle;
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Error).unwrap();
|
||||
let s = s.transition_to(ConvState::Generating).unwrap();
|
||||
let s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_transition_error_display() {
|
||||
// Display 包含 from / to,供日志诊断
|
||||
let err = ConvState::Idle
|
||||
.transition_to(ConvState::Stopping)
|
||||
.unwrap_err();
|
||||
let msg = format!("{}", err);
|
||||
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
||||
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::ai::AiSession;
|
||||
use crate::commands::ai::{AiChatEvent, AiSession};
|
||||
|
||||
use super::conv_state::{self, ConvState};
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
///
|
||||
@@ -22,25 +25,106 @@ use crate::commands::ai::AiSession;
|
||||
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
|
||||
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
|
||||
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
|
||||
///
|
||||
/// L2 统一状态机收尾(2026-06-22):本地 `state: Option<ConvState>` 视图层冗余字段已删除。
|
||||
///
|
||||
/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到
|
||||
/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读
|
||||
/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余
|
||||
/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。
|
||||
///
|
||||
/// 收尾删除后:
|
||||
/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端
|
||||
/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。
|
||||
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
|
||||
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
|
||||
///
|
||||
/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
||||
/// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String) -> Self {
|
||||
Self { session, conv_id, done: false }
|
||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
|
||||
// L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state),
|
||||
// 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state
|
||||
// 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。
|
||||
//
|
||||
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
|
||||
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
|
||||
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
let session = session.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
let cid = conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let new_state = {
|
||||
let mut session = session.lock().await;
|
||||
let conv = session.conv(&cid);
|
||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
ns
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: new_state,
|
||||
conversation_id: Some(cid),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
}
|
||||
Self { session, conv_id, done: false, app_handle }
|
||||
}
|
||||
|
||||
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
||||
///
|
||||
/// 仅写 per_conv.conv_id.generating(唯一真相源)。
|
||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
let mut session = self.session.lock().await;
|
||||
session.conv(&self.conv_id).generating = false;
|
||||
let conv = session.conv(&self.conv_id);
|
||||
conv.generating = false;
|
||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
};
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||
),
|
||||
}
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +132,9 @@ impl GeneratingGuard {
|
||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
||||
///
|
||||
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
|
||||
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
@@ -55,13 +142,82 @@ impl GeneratingGuard {
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
// 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位,
|
||||
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
|
||||
// 开关 off / done=true(正常 reset/disarm 已走) → 跳过。
|
||||
if !self.done {
|
||||
let session = self.session.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
|
||||
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
|
||||
let app_handle = self.app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut s = session.lock().await;
|
||||
s.conv(&conv_id).generating = false;
|
||||
let conv = s.conv(&conv_id);
|
||||
conv.generating = false;
|
||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||
conv.conv_state = ns;
|
||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。
|
||||
///
|
||||
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义——
|
||||
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
|
||||
#[test]
|
||||
fn test_guard_new_migrates_to_generating() {
|
||||
// CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。
|
||||
assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设");
|
||||
let mut s = ConvState::Idle;
|
||||
s = s.transition_to(ConvState::Generating).unwrap();
|
||||
assert_eq!(s, ConvState::Generating);
|
||||
}
|
||||
|
||||
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_reset_migrates_to_idle() {
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_drop_path_migrates_to_idle() {
|
||||
// guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// disarm 不迁移状态(审批等待保持 Generating)。
|
||||
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
|
||||
#[test]
|
||||
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
|
||||
let s = ConvState::Generating;
|
||||
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
|
||||
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
@@ -21,7 +21,7 @@ use df_ai::context_helpers::{
|
||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||
use df_ai::retry;
|
||||
@@ -106,6 +106,30 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
|
||||
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
|
||||
pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
/// 耗尽 max_iterations 前 0 产出。机制(非 prompt 教 AI):每轮 process_tool_calls
|
||||
/// 后取末尾连续 Tool 消息,失败内容前 40 字符归一为 key 计数,同一 key 累计达此阈值 →
|
||||
/// guard.reset + emit AiError + return 强制熔断,逼用户换思路或人工介入。
|
||||
///
|
||||
/// 阈值 3:同类失败 3 次足以判死循环(去重后仍累加,不同错误各自计数互不干扰)。
|
||||
pub const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
|
||||
|
||||
/// L1 断路器总开关(默认 true)。false → 跳过断路器检查,降级为纯 max_iterations
|
||||
/// 旧行为(排障/对比/临时关闭用)。机制优先 prompt 说教,每改配开关 + 兜底(关降级旧行为)。
|
||||
pub const CIRCUIT_BREAKER_ENABLED: bool = true;
|
||||
|
||||
/// L1 断路器熔断时是否发结构化求助(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21)。
|
||||
///
|
||||
/// true(默认):熔断 emit AiHelpRequired(结构化求助卡:reason + context + options),
|
||||
/// 引导用户换思路/授权路径/人工接管(机制优先 prompt 说教,非教 AI 自己止损)。
|
||||
/// false(兜底回退):熔断仍 emit AiError(旧行为,前端错误气泡),用于求助卡未就绪/
|
||||
/// 排障/对比。两路保留 guard.reset + return 强制熔断语义不变,仅换前端呈现形态。
|
||||
/// 配合 CIRCUIT_BREAKER_ENABLED:CIRCUIT_BREAKER_ENABLED=false 时断路器整段跳过,
|
||||
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
|
||||
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
|
||||
|
||||
// 阶段2(path_auth 审批链重构):占位配对完整性开关(解 400 orphan)。
|
||||
//
|
||||
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
|
||||
@@ -131,6 +155,22 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
mod guard;
|
||||
use guard::GeneratingGuard;
|
||||
|
||||
// ============================================================
|
||||
// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。
|
||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||
//
|
||||
// 本批范围(渐进):
|
||||
// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。
|
||||
// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState
|
||||
// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。
|
||||
// - guard.reset 同步 ConvState→Idle(正常退出路径)。
|
||||
// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。
|
||||
//
|
||||
// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。
|
||||
// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
|
||||
// ============================================================
|
||||
// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助
|
||||
// ============================================================
|
||||
@@ -314,11 +354,14 @@ async fn stream_one_provider(
|
||||
candidate.name, delay.as_millis(), total_retry, max_retries + 1,
|
||||
);
|
||||
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
|
||||
let ev = AiChatEvent::AiStreamRetry {
|
||||
attempt: total_retry as u32,
|
||||
max_attempts: (max_retries + 1) as u32,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
@@ -356,7 +399,8 @@ pub(crate) async fn run_agentic_loop(
|
||||
) {
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone());
|
||||
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
|
||||
|
||||
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
||||
//
|
||||
@@ -374,10 +418,27 @@ pub(crate) async fn run_agentic_loop(
|
||||
//
|
||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||
//
|
||||
// L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。
|
||||
// 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum
|
||||
// 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.generating = true;
|
||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。
|
||||
// enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
||||
@@ -456,11 +517,18 @@ pub(crate) async fn run_agentic_loop(
|
||||
Ok(Err(ProviderResolveError::EnsureKeyFailed(msg))) => {
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: msg,
|
||||
error: msg.clone(),
|
||||
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth
|
||||
error_type: Some(ErrorType::Auth),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// L3 emit 双写(2026-06-22):关键 AiError publish 到事件总线,供跨模块订阅。
|
||||
// EVENT_BUS_ENABLED 门控在 publish 内部(false 静默丢弃),无消费者时空转不报错。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: msg,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
@@ -468,11 +536,18 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 走 AiError 分支复位 generating,对齐 ensure_resolved_key 失败处理口径。
|
||||
guard.reset().await;
|
||||
tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死");
|
||||
let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string();
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(),
|
||||
error: err_msg.clone(),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// L3 emit 双写:超时 AiError publish 到事件总线(同上,门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: err_msg,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -601,6 +676,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
// L1 断路器:连续同类工具失败计数器(key=失败内容前 40 字符,value=累计次数)。
|
||||
// loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
let mut fail_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
|
||||
@@ -636,6 +715,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
// L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -717,8 +804,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
|
||||
if iteration > 0 {
|
||||
let round_n = (iteration + 1) as u32;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: (iteration + 1) as u32,
|
||||
round: round_n,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// L3 emit 双写:AiAgentRound publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: round_n,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
}
|
||||
@@ -771,9 +864,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
if should_compress {
|
||||
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressing {
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
let (active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
@@ -833,10 +929,13 @@ pub(crate) async fn run_agentic_loop(
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 自动压缩成功,摘要已插首位"
|
||||
);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompressed {
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
summary,
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Ok(None) => {
|
||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
||||
@@ -883,11 +982,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,10 +1182,16 @@ pub(crate) async fn run_agentic_loop(
|
||||
// UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)。
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error,
|
||||
error: error.clone(),
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1101,12 +1209,22 @@ pub(crate) async fn run_agentic_loop(
|
||||
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
|
||||
);
|
||||
guard.reset().await;
|
||||
let err_msg = last_exhausted_error
|
||||
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string());
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: last_exhausted_error
|
||||
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()),
|
||||
error: err_msg.clone(),
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// L3 emit 双写:全 candidate 耗尽 AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: err_msg,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
// BUG-260623-02:全 provider 失败也需落库 user 消息(已 push 内存 chat.rs:383),
|
||||
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
|
||||
save_conversation(&session_arc, &db, &conv_id, None, Some(&resolved_model), true).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1161,6 +1279,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
incomplete: Some(true),
|
||||
});
|
||||
// L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// F-260616-09 B 批5: global/per_conv permit 已上移 loop 入口(L520-521)整 loop 持有,
|
||||
@@ -1221,6 +1347,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
// L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage.total_tokens,
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1374,95 @@ pub(crate) async fn run_agentic_loop(
|
||||
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
|
||||
);
|
||||
|
||||
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
|
||||
// CIRCUIT_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。
|
||||
// 仅检查自动执行(Low)的工具结果——pending_count>0(待审批)交给下方审批分支,
|
||||
// 此处只看已回填的 Tool 消息。取末尾连续 Tool 消息(倒序 take_while role==Tool),
|
||||
// 失败内容前 40 字符归一 key 计数,同 key 累计达阈值 → guard.reset + AiError + return。
|
||||
if CIRCUIT_BREAKER_ENABLED {
|
||||
let (max_count, sample_key) = {
|
||||
let session = session_arc.lock().await;
|
||||
// conv 可能已被删除(stop/新对话),get 不到 → 无消息可判,跳过本轮断路器检查。
|
||||
let messages = match session.conv_read(&conv_id) {
|
||||
Some(conv) => conv.messages.all_messages_clone(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// 倒序取末尾连续 role==Tool 消息(本轮工具回填结果;非 Tool 即停)。
|
||||
// MessageRole 未派生 PartialEq,用 matches! 宏判变体(不改共享类型 df-ai-core)。
|
||||
let recent_tool_results: Vec<&ChatMessage> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
for m in recent_tool_results {
|
||||
let content = m.content.as_str();
|
||||
// 失败判定:禁止/跳过重试/失败/Error 关键词(覆盖权限拒绝/路径错误/异常等)。
|
||||
let is_failure = content.starts_with("禁止")
|
||||
|| content.starts_with("已跳过重试")
|
||||
|| content.contains("失败")
|
||||
|| content.contains("Error")
|
||||
|| content.contains("error");
|
||||
if is_failure {
|
||||
// 前 40 字符归一 key:同类失败(同前缀)累加,不同错误各自计数互不干扰。
|
||||
let key: String = content.chars().take(40).collect();
|
||||
*fail_counts.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
// 取当前最大计数及其 key(无失败 → max_count=0,不触发)。
|
||||
fail_counts
|
||||
.iter()
|
||||
.max_by_key(|(_, &v)| v)
|
||||
.map(|(k, &v)| (v, k.clone()))
|
||||
.unwrap_or((0u32, String::new()))
|
||||
};
|
||||
// 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。
|
||||
if max_count >= CIRCUIT_BREAKER_THRESHOLD {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
max_count,
|
||||
sample_key = %sample_key,
|
||||
"[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count
|
||||
);
|
||||
guard.reset().await;
|
||||
// L1 求助协议(§2.3,2026-06-21):熔断改发结构化 AiHelpRequired(机制优先 prompt 说教)。
|
||||
// 开关 CIRCUIT_BREAKER_HELP_EVENT=true(默认)→ AiHelpRequired(求助卡,显 reason/options
|
||||
// 供用户选);false(兜底回退)→ AiError(旧错误气泡)。两路均 guard.reset + return 强制熔断,
|
||||
// 仅前端呈现形态不同,语义不变(均终止 loop,逼用户介入)。
|
||||
if CIRCUIT_BREAKER_HELP_EVENT {
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!(
|
||||
"连续同类失败 {} 次,疑似死循环已停止",
|
||||
max_count
|
||||
),
|
||||
context: format!("最近错误: {}", sample_key),
|
||||
options: vec![
|
||||
"换思路".into(),
|
||||
"授权路径".into(),
|
||||
"人工接管".into(),
|
||||
],
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 兜底回退:求助卡未就绪/排障/对比时,沿用旧 AiError 错误气泡呈现。
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiError {
|
||||
error: format!(
|
||||
"连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}",
|
||||
max_count, sample_key
|
||||
),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复
|
||||
if pending_count > 0 {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
@@ -1280,9 +1503,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
|
||||
// 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位
|
||||
guard.disarm();
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached {
|
||||
let ev = AiChatEvent::AiMaxRoundsReached {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return; // generating 保持 true,等 ai_continue_loop / ai_stop_loop
|
||||
}
|
||||
|
||||
@@ -1319,6 +1545,15 @@ pub(crate) async fn run_agentic_loop(
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
// L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内,
|
||||
// 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiCompleted {
|
||||
total_tokens: usage_total,
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
@@ -1375,7 +1610,15 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
||||
let is_generating = conv.map(|c| c.generating).unwrap_or(false);
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
|
||||
// off 回退 generating bool。双轨过渡:off 等价旧行为。
|
||||
let is_generating = conv.map(|c| {
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
ContinueSnapshot {
|
||||
@@ -1404,13 +1647,16 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1429,11 +1675,17 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
drop(session);
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e,
|
||||
error: e.clone(),
|
||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
||||
error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1469,19 +1721,29 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool。
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| c.generating).unwrap_or(false)
|
||||
session.conv_read(conv_id).map(|c| {
|
||||
if conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false)
|
||||
};
|
||||
if !still_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1491,6 +1753,11 @@ pub(crate) async fn try_continue_agent_loop(
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||
|
||||
@@ -282,12 +282,16 @@ pub(crate) async fn process_tool_calls(
|
||||
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
|
||||
.map(|(idx, draft)| {
|
||||
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted {
|
||||
// L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。
|
||||
// AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。
|
||||
let ev = AiChatEvent::AiToolCallStarted {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
(idx, draft, args)
|
||||
})
|
||||
.collect();
|
||||
@@ -325,11 +329,14 @@ pub(crate) async fn process_tool_calls(
|
||||
for (draft, reason) in path_denied {
|
||||
let err_msg = format!("路径授权拒绝: {}", reason);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
@@ -362,11 +369,14 @@ pub(crate) async fn process_tool_calls(
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:重试跳过 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
@@ -393,13 +403,16 @@ pub(crate) async fn process_tool_calls(
|
||||
// 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
|
||||
// L3 emit 双写:路径授权挂起 AiDirAuthRequired 双路发布(tunnel 透传 miniapp 弹窗)。
|
||||
let ev = AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
path: path_str,
|
||||
dir: dir_str,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
@@ -414,17 +427,43 @@ pub(crate) async fn process_tool_calls(
|
||||
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
// F-#97:low_risk 向量携带 risk_level(治 securityReview blocker :710 审计失真)。
|
||||
// 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式)
|
||||
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
|
||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
||||
// F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。
|
||||
// low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。
|
||||
// 锁内 await KV 快读,不竞争 session 锁(settings 独立 repo)。
|
||||
let auto_exec_mode = app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.settings
|
||||
.get("df-ai-auto-execute-mode")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "low".to_string());
|
||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||
// (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||
// 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
match risk_level {
|
||||
RiskLevel::Low => low_risk.push((draft, args)),
|
||||
RiskLevel::Medium | RiskLevel::High => {
|
||||
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// F-#97:按 auto_exec_mode 判定是否自动执行。should_auto=true 走 low_risk(自动),false 走原审批块。
|
||||
// low(默认):Low→auto, Med/High→审批(等价现状)
|
||||
// medium:Low/Med→auto, High→审批
|
||||
// all:全 auto(完全接管,无审批)
|
||||
let should_auto = match risk_level {
|
||||
RiskLevel::Low => true,
|
||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||
RiskLevel::High => auto_exec_mode == "all",
|
||||
};
|
||||
if should_auto {
|
||||
low_risk.push((draft, args, risk_level));
|
||||
} else {
|
||||
// 原 Medium|High 审批分支(trust_key 命中/dedup 缓存/retry guard/pending insert + emit + audit_tool_call)
|
||||
// 整体保留,逻辑不变。trust_hits 元组携带 risk_level 语义不变:Med 进此处=Medium,
|
||||
// High 进此处(仅 low/medium 模式)时=High。
|
||||
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
|
||||
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
|
||||
@@ -450,12 +489,15 @@ pub(crate) async fn process_tool_calls(
|
||||
);
|
||||
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
|
||||
// toast 即时反馈,锁内 emit 不阻塞
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolAutoApproved {
|
||||
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
|
||||
let ev = AiChatEvent::AiToolAutoApproved {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
dir: dir_label.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
|
||||
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
|
||||
trust_hits.push((draft, args, dir_label, risk_level));
|
||||
@@ -472,11 +514,14 @@ pub(crate) async fn process_tool_calls(
|
||||
);
|
||||
// F-260616-09 B 批2:写 per_conv.messages(conv_id 来源:本函数入参)。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(cached.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
|
||||
continue;
|
||||
@@ -504,11 +549,14 @@ pub(crate) async fn process_tool_calls(
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||
continue;
|
||||
}
|
||||
@@ -525,16 +573,18 @@ pub(crate) async fn process_tool_calls(
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
|
||||
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||
let ev = AiChatEvent::AiApprovalRequired {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
reason,
|
||||
diff: approval_diff,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,11 +605,14 @@ pub(crate) async fn process_tool_calls(
|
||||
match exec_result {
|
||||
Ok(val) => {
|
||||
let content = val.to_string();
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(content.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// AR-11:数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
||||
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
@@ -567,11 +620,14 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
Err(e) => {
|
||||
let content = e.to_string();
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(content.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
(draft, risk_level, Err(content))
|
||||
}
|
||||
}
|
||||
@@ -596,8 +652,8 @@ pub(crate) async fn process_tool_calls(
|
||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !low_risk.is_empty() {
|
||||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||
let results: Vec<(ToolCallDraft, serde_json::Value, Result<String, String>)> =
|
||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
|
||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
@@ -605,15 +661,18 @@ pub(crate) async fn process_tool_calls(
|
||||
let result = tools.execute(&draft.name, args).await;
|
||||
match result {
|
||||
Ok(val) => {
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: val.clone(),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, val.clone(), Ok(val.to_string()))
|
||||
(draft, val.clone(), risk_level, Ok(val.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// AR-6(定向B):Low 工具失败不 emit AiError。
|
||||
@@ -622,19 +681,22 @@ pub(crate) async fn process_tool_calls(
|
||||
// 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result
|
||||
// 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。
|
||||
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
|
||||
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
// L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
});
|
||||
(draft, serde_json::Value::Null, Err(err_msg))
|
||||
};
|
||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
// 串行回填 tool_result + 审计(持 session 锁)
|
||||
for (draft, raw_result, outcome) in results {
|
||||
for (draft, raw_result, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
@@ -648,7 +710,15 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
// F-260616-09 B 批2:写 per_conv.messages。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await;
|
||||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
|
||||
let decided_by: &str = match risk_level {
|
||||
RiskLevel::Low => "auto",
|
||||
RiskLevel::Medium => "auto_takeover_medium",
|
||||
RiskLevel::High => "auto_takeover_all",
|
||||
};
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
541
src-tauri/src/commands/ai/code_intel.rs
Normal file
541
src-tauri/src/commands/ai/code_intel.rs
Normal file
@@ -0,0 +1,541 @@
|
||||
//! AST 代码智能 — tree-sitter 符号解析(read_symbol 三态)
|
||||
//!
|
||||
//! 治 aichat read_file 全文回灌 prompt 爆(信息密度驱动 ≠ 压缩,见设计文档):
|
||||
//! read_symbol 按语义层级返符号 **骨架**(默认)/ **全文**(full),替代物理读全文件。
|
||||
//! LLM 默认拿骨架(签名 + 行范围 + 内部调用)→ 要细节再 full 取完整定义体 → 进 prompt 的都是主动要的,天然高密度。
|
||||
//! 设计:docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md
|
||||
//!
|
||||
//! **不报错底线**:无 grammar / 解析失败 / symbol 未找到 / Vue 无 script / 任何大小函数 →
|
||||
//! 返兜底提示(grep + read_file),不 Err / 不 panic / 不阻断。
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tree_sitter::{Language, Node, Parser};
|
||||
|
||||
/// Phase1 支持的定义节点 kind(Rust + TS/JS 通用,Vue 借 TS)。
|
||||
///
|
||||
/// tree-sitter 各 grammar 的定义节点 kind 基本一致:
|
||||
/// - Rust:function_item / struct_item / impl_item 等(`_item` 后缀)
|
||||
/// - TS/JS:function_declaration / class_declaration / interface_declaration / method_definition 等
|
||||
/// 统一放一张表(find_definition 命中即提取),跨语言零分支。
|
||||
const DEFINITION_KINDS: &[&str] = &[
|
||||
// Rust
|
||||
"function_item",
|
||||
"function_signature_item",
|
||||
"struct_item",
|
||||
"enum_item",
|
||||
"impl_item",
|
||||
"trait_item",
|
||||
"mod_item",
|
||||
"const_item",
|
||||
"static_item",
|
||||
"type_item",
|
||||
"macro_definition",
|
||||
// TS / JS(与 Rust kind 不冲突)
|
||||
"function_declaration",
|
||||
"generator_function_declaration",
|
||||
"class_declaration",
|
||||
"abstract_class_declaration",
|
||||
"interface_declaration",
|
||||
"enum_declaration",
|
||||
"method_definition",
|
||||
];
|
||||
|
||||
/// 文件后缀 → tree-sitter grammar 集中 lookup(静态编译,不动态加载 / 不抽 trait)。
|
||||
///
|
||||
/// 设计(grammar 策略 2026-06-24 定稿,详见插件机制设计文档):
|
||||
/// **3 个 grammar 覆盖 4 类文件**,架构成本最低。
|
||||
/// - `.rs` → tree-sitter-rust
|
||||
/// - `.ts` → tree-sitter-typescript(language_typescript)
|
||||
/// - `.tsx` → tree-sitter-typescript(language_tsx)
|
||||
/// - `.js` / `.jsx` → tree-sitter-javascript
|
||||
/// - `.vue` → 返回 None 标记"需切 script 段"(Vue 借 TS,逻辑在 script,read_symbol 内 extract_vue_script 切段喂 ts)
|
||||
///
|
||||
/// 未来加语言 = 此处加一行 + 可能补 DEFINITION_KINDS(表驱动,~几行),不返工调用方。
|
||||
/// tree-sitter 动态加载技术上可行(Helix/Neovim 先例)但跨平台分发 + ABI 坑,当前不值(见插件设计)。
|
||||
pub fn grammar_for(ext: &str) -> Option<Language> {
|
||||
match ext {
|
||||
"rs" => Some(Language::from(tree_sitter_rust::LANGUAGE)),
|
||||
"ts" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT)),
|
||||
"tsx" => Some(Language::from(tree_sitter_typescript::LANGUAGE_TSX)),
|
||||
"js" | "jsx" => Some(Language::from(tree_sitter_javascript::LANGUAGE)),
|
||||
// Vue 借 TS:返 None 标记需切 script 段(read_symbol 内 extract_vue_script + 喂 ts)
|
||||
"vue" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 ext 是否为 Phase1 支持语言(含 Vue 借 TS)。
|
||||
///
|
||||
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
|
||||
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
|
||||
#[allow(dead_code)]
|
||||
pub fn is_supported_ext(ext: &str) -> bool {
|
||||
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue")
|
||||
}
|
||||
|
||||
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
|
||||
///
|
||||
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
|
||||
/// 切出 `<script>...</script>` 内容喂 tree-sitter-typescript:
|
||||
/// - 省 1 个 grammar(tree-sitter-vue 不引,降依赖体积)
|
||||
/// - 避 SFC 嵌套复杂度(tree-sitter-vue 解析 template/style/script 混合,符号提取只关心 script)
|
||||
///
|
||||
/// 多个 script 段取首个(`<script setup>` 主逻辑);无 script 返 None(走兜底)。
|
||||
/// 返回 (script 内容, script 在原 source 中的字节起始偏移)用于行号映射。
|
||||
fn extract_vue_script(source: &str) -> Option<(String, usize)> {
|
||||
let start_tag = source.find("<script")?;
|
||||
// 跳过 <script ...> 的属性(到 >),取标签体开始
|
||||
let tag_close = source[start_tag..].find('>')? + start_tag + 1;
|
||||
let end = source[tag_close..].find("</script>")?;
|
||||
Some((source[tag_close..tag_close + end].to_string(), tag_close))
|
||||
}
|
||||
|
||||
/// read_symbol 三态读取(信息密度驱动)。
|
||||
///
|
||||
/// **不按物理行返、不截断**,按语义结构层级返(消解"截断多少行"争论):
|
||||
/// - `full=false`(默认 骨架态):返签名 + kind + 行范围 + 内部调用列表 + 总行数(极小,高密度)
|
||||
/// - `full=true`(全文态):返完整定义体(语法边界 100% 准)
|
||||
///
|
||||
/// **不报错底线**:无 grammar / 解析失败 / symbol 未找到 / Vue 无 script → 返兜底提示(grep + read_file),不 panic。
|
||||
///
|
||||
/// 参数:
|
||||
/// - `source`:文件全文(由 handler 读 + 算 hash 后传入,纯函数不碰 fs)
|
||||
/// - `file_hash`:文件 hash(handler 算,回传 LLM 供后续定位 / 兜底回取)
|
||||
/// - `path`:路径(取 ext + 兜底消息展示)
|
||||
/// - `symbol`:目标符号名(精确匹配定义节点的 name field)
|
||||
/// - `full`:true=完整定义体,false=骨架
|
||||
/// - `kind_hint`:可选 kind 过滤(如 "function"/"struct"),None 匹配任意定义 kind
|
||||
pub fn read_symbol(
|
||||
source: &str,
|
||||
file_hash: &str,
|
||||
path: &str,
|
||||
symbol: &str,
|
||||
full: bool,
|
||||
kind_hint: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_vue = ext == "vue";
|
||||
|
||||
// Vue 借 TS:切 script 段(Vue 逻辑在 script,template/style 不做符号)
|
||||
let (parse_source, parse_ext, line_offset): (String, &str, usize) = if is_vue {
|
||||
match extract_vue_script(source) {
|
||||
Some((script, byte_off)) => {
|
||||
// Vue 切段后节点行号是 script 内相对行,加原文件中 script 起始行偏移映射回原行号
|
||||
let off_lines = source[..byte_off].matches('\n').count();
|
||||
(script, "ts", off_lines)
|
||||
}
|
||||
None => return fallback(symbol, path, file_hash, "vue_no_script",
|
||||
"Vue 文件无 <script> 段(纯 template/style 无可提取符号)"),
|
||||
}
|
||||
} else {
|
||||
(source.to_string(), ext.as_str(), 0)
|
||||
};
|
||||
|
||||
let grammar = match grammar_for(parse_ext) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
return fallback(symbol, path, file_hash, "no_grammar",
|
||||
&format!("不支持 {} 文件符号解析(Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue)", ext));
|
||||
}
|
||||
};
|
||||
|
||||
let mut parser = Parser::new();
|
||||
if parser.set_language(&grammar).is_err() {
|
||||
return fallback(symbol, path, file_hash, "parse_failed",
|
||||
"grammar 加载失败(tree-sitter 版本/ABI 不兼容,疑似环境问题)");
|
||||
}
|
||||
let tree = match parser.parse(&parse_source, None) {
|
||||
Some(t) => t,
|
||||
None => return fallback(symbol, path, file_hash, "parse_failed", "解析失败(源码可能有语法错误)"),
|
||||
};
|
||||
|
||||
let root = tree.root_node();
|
||||
let mut found: Option<Node> = None;
|
||||
find_definition(&root, symbol, kind_hint, parse_source.as_bytes(), &mut found);
|
||||
|
||||
match found {
|
||||
Some(node) => extract_symbol(node, parse_source.as_bytes(), source, path, file_hash, full, line_offset),
|
||||
None => fallback(symbol, path, file_hash, "not_found",
|
||||
&format!("文件内未找到符号「{}」(可能拼写/作用域差异,试 grep 搜定义)", symbol)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归遍历找匹配 symbol 名的定义节点。
|
||||
///
|
||||
/// 命中条件:node.kind() 在 DEFINITION_KINDS 内 + name field 文本 == symbol + kind_hint(若传)匹配。
|
||||
/// 首个命中即返(find_definition 调用方短路)。
|
||||
fn find_definition<'a>(
|
||||
node: &Node<'a>,
|
||||
symbol: &str,
|
||||
kind_hint: Option<&str>,
|
||||
source: &[u8],
|
||||
found: &mut Option<Node<'a>>,
|
||||
) {
|
||||
if found.is_some() {
|
||||
return;
|
||||
}
|
||||
if DEFINITION_KINDS.contains(&node.kind()) {
|
||||
let kind_ok = kind_hint.map_or(true, |h| kind_matches(node.kind(), h));
|
||||
if kind_ok {
|
||||
if let Some(name_node) = node.child_by_field_name("name") {
|
||||
let name = node_text(name_node, source);
|
||||
if name == symbol {
|
||||
*found = Some(*node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
find_definition(&child, symbol, kind_hint, source, found);
|
||||
if found.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// kind_hint 宽松匹配:user 传 "function" 命中 function_item/function_declaration/method_definition 等。
|
||||
fn kind_matches(node_kind: &str, hint: &str) -> bool {
|
||||
let h = hint.to_lowercase();
|
||||
node_kind.contains(&h)
|
||||
|| (h == "function" && (node_kind.contains("function") || node_kind == "method_definition"))
|
||||
|| (h == "struct" && node_kind.contains("struct"))
|
||||
|| (h == "class" && node_kind.contains("class"))
|
||||
|| (h == "enum" && node_kind.contains("enum"))
|
||||
}
|
||||
|
||||
/// 取节点文本(字节切片避免 utf8_text 的 lifetime/Result 坑)。
|
||||
/// 返 owned String(符号体不大,避免借用 lifetime 纠缠调用方)。
|
||||
fn node_text(node: Node<'_>, source: &[u8]) -> String {
|
||||
std::str::from_utf8(&source[node.start_byte()..node.end_byte()])
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 提取符号三态信息,组装返回 Value。
|
||||
fn extract_symbol(
|
||||
node: Node<'_>,
|
||||
source: &[u8],
|
||||
_orig_source: &str,
|
||||
path: &str,
|
||||
file_hash: &str,
|
||||
full: bool,
|
||||
line_offset: usize,
|
||||
) -> serde_json::Value {
|
||||
let name = node
|
||||
.child_by_field_name("name")
|
||||
.map(|n| node_text(n, source).to_string())
|
||||
.unwrap_or_default();
|
||||
let kind = node.kind().to_string();
|
||||
|
||||
// 行号:script 内相对行(0-based)+ Vue 偏移 → 原文件 1-based 行
|
||||
let start_row = node.start_position().row + 1 + line_offset;
|
||||
let end_row = node.end_position().row + 1 + line_offset;
|
||||
let body_lines = end_row - start_row + 1;
|
||||
|
||||
let node_src = node_text(node, source);
|
||||
let signature = extract_signature(&node_src);
|
||||
let calls = collect_calls(node, source);
|
||||
|
||||
if full {
|
||||
serde_json::json!({
|
||||
"symbol": name,
|
||||
"kind": kind,
|
||||
"path": path,
|
||||
"file_hash": file_hash,
|
||||
"line_start": start_row,
|
||||
"line_end": end_row,
|
||||
"body_lines": body_lines,
|
||||
"signature": signature,
|
||||
"calls": calls,
|
||||
"mode": "full",
|
||||
"content": node_src,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"symbol": name,
|
||||
"kind": kind,
|
||||
"path": path,
|
||||
"file_hash": file_hash,
|
||||
"line_start": start_row,
|
||||
"line_end": end_row,
|
||||
"body_lines": body_lines,
|
||||
"signature": signature,
|
||||
"calls": calls,
|
||||
"mode": "skeleton",
|
||||
"hint": "骨架态:传 full=true 取完整定义体(语法边界精准);下钻局部分支 drill 待 Phase2",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取签名:节点文本到首个 `{`(函数/结构体体开始)或 `;`(声明)之前的部分。
|
||||
/// 无 `{`/`;` 取首行。trim 收尾空白 + 注释。
|
||||
fn extract_signature(node_src: &str) -> String {
|
||||
let cut = node_src
|
||||
.find('{')
|
||||
.or_else(|| node_src.find(';'))
|
||||
.unwrap_or_else(|| node_src.len());
|
||||
let sig = node_src[..cut].trim();
|
||||
if sig.is_empty() {
|
||||
node_src.lines().next().unwrap_or("").trim().to_string()
|
||||
} else {
|
||||
sig.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集节点内所有 call_expression 的被调用者(同文件静态调用点)。
|
||||
///
|
||||
/// Phase1:扁平收集去重(Phase2 按分支结构组织调用图)。
|
||||
/// Rust/TS/JS 的 call_expression 都有 "function" field(callee),统一提取。
|
||||
fn collect_calls(node: Node, source: &[u8]) -> Vec<String> {
|
||||
let mut calls = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
collect_calls_inner(node, source, &mut calls, &mut seen);
|
||||
calls
|
||||
}
|
||||
|
||||
fn collect_calls_inner(node: Node, source: &[u8], out: &mut Vec<String>, seen: &mut HashSet<String>) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "call_expression" {
|
||||
if let Some(func) = child.child_by_field_name("function") {
|
||||
let name = node_text(func, source).to_string();
|
||||
if !name.is_empty() && seen.insert(name.clone()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
collect_calls_inner(child, source, out, seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。
|
||||
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"symbol": symbol,
|
||||
"path": path,
|
||||
"file_hash": file_hash,
|
||||
"fallback": true,
|
||||
"reason": reason,
|
||||
"suggestion": suggestion,
|
||||
"tip": "回退:用 grep(pattern=symbol) 搜定义位置 → read_file(path, offset=行号) 精准读取",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:三态提取 + 兜底 + grammar lookup + Vue 切段
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RUST_FIXTURE: &str = r#"use std::io;
|
||||
|
||||
pub fn read_symbol(path: &str) -> Result<(), io::Error> {
|
||||
let content = load(path)?;
|
||||
parse(&content);
|
||||
validate(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse(content: &str) {
|
||||
helper(content);
|
||||
}
|
||||
|
||||
fn helper(_x: &str) {}
|
||||
|
||||
struct Config {
|
||||
name: String,
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_grammar_for_all_exts() {
|
||||
assert!(grammar_for("rs").is_some());
|
||||
assert!(grammar_for("ts").is_some());
|
||||
assert!(grammar_for("tsx").is_some());
|
||||
assert!(grammar_for("js").is_some());
|
||||
assert!(grammar_for("jsx").is_some());
|
||||
// Vue 借 TS:grammar 本身返 None(切段后用 ts)
|
||||
assert!(grammar_for("vue").is_none());
|
||||
// 不支持语言
|
||||
assert!(grammar_for("py").is_none());
|
||||
assert!(grammar_for("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_ext() {
|
||||
assert!(is_supported_ext("rs"));
|
||||
assert!(is_supported_ext("vue"));
|
||||
assert!(!is_supported_ext("py"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_rust_skeleton() {
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", false, None);
|
||||
assert_eq!(r["fallback"], serde_json::Value::Null);
|
||||
assert_eq!(r["symbol"], "read_symbol");
|
||||
assert_eq!(r["kind"], "function_item");
|
||||
assert_eq!(r["mode"], "skeleton");
|
||||
// 骨架态不返 content,返 signature + calls
|
||||
assert!(r["content"].is_null());
|
||||
assert!(r["signature"].as_str().unwrap().contains("read_symbol"));
|
||||
// calls 含同文件静态调用(parse/validate)+ load(跨文件也命中名,Phase1 扁平)
|
||||
let calls = r["calls"].as_array().unwrap();
|
||||
assert!(calls.iter().any(|c| c == "parse"));
|
||||
assert!(calls.iter().any(|c| c == "validate"));
|
||||
assert_eq!(r["body_lines"].as_u64().unwrap(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_rust_full() {
|
||||
let r = read_symbol(RUST_FIXTURE, "hash1", "test.rs", "read_symbol", true, None);
|
||||
assert_eq!(r["mode"], "full");
|
||||
let content = r["content"].as_str().unwrap();
|
||||
// full 态返完整定义体(含函数体 {})
|
||||
assert!(content.contains("pub fn read_symbol"));
|
||||
assert!(content.contains("let content = load(path)?;"));
|
||||
assert!(content.trim_end().ends_with('}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_struct() {
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, None);
|
||||
assert_eq!(r["symbol"], "Config");
|
||||
assert_eq!(r["kind"], "struct_item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_kind_hint_filter() {
|
||||
// kind_hint="struct" 时,"read_symbol"(function)不应被误匹配为 struct
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "Config", false, Some("struct"));
|
||||
assert_eq!(r["symbol"], "Config");
|
||||
assert_eq!(r["kind"], "struct_item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_not_found_fallback() {
|
||||
let r = read_symbol(RUST_FIXTURE, "h", "test.rs", "nonexistent_fn", false, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "not_found");
|
||||
assert!(r["suggestion"].as_str().unwrap().contains("grep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_no_grammar_fallback() {
|
||||
let src = "def hello():\n print('hi')\n";
|
||||
let r = read_symbol(src, "h", "test.py", "hello", false, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "no_grammar");
|
||||
assert!(r["suggestion"].as_str().unwrap().contains("不支持"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_vue_script_extraction() {
|
||||
let vue = r#"<template><div>{{ msg }}</div></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function greet(name: string) {
|
||||
console.log(name);
|
||||
helper();
|
||||
}
|
||||
|
||||
function helper() {}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
"#;
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
|
||||
assert_eq!(r["fallback"], serde_json::Value::Null);
|
||||
assert_eq!(r["symbol"], "greet");
|
||||
assert_eq!(r["kind"], "function_declaration");
|
||||
// Vue 切段后行号映射回原文件(greet 在原文件第 5 行附近)
|
||||
let start = r["line_start"].as_u64().unwrap();
|
||||
assert!(start >= 4, "Vue 行号应映射回原文件(script 段偏移后),实际 {}", start);
|
||||
let calls = r["calls"].as_array().unwrap();
|
||||
assert!(calls.iter().any(|c| c == "helper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_symbol_vue_no_script_fallback() {
|
||||
let vue = "<template><div>hi</div></template>\n<style></style>";
|
||||
let r = read_symbol(vue, "h", "App.vue", "greet", false, None);
|
||||
assert_eq!(r["fallback"], true);
|
||||
assert_eq!(r["reason"], "vue_no_script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_vue_script() {
|
||||
let vue = "a\nb\n<script>let x = 1;</script>";
|
||||
let (script, off) = extract_vue_script(vue).unwrap();
|
||||
assert_eq!(script, "let x = 1;");
|
||||
// script 在第 3 行(0-indexed offset 后),行偏移应 = 2(\n 数)
|
||||
assert_eq!(vue[..off].matches('\n').count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_signature() {
|
||||
// function 带 body
|
||||
let s = extract_signature("pub fn foo(x: i32) -> i32 {\n x + 1\n}");
|
||||
assert_eq!(s, "pub fn foo(x: i32) -> i32");
|
||||
// struct 带 body
|
||||
let s = extract_signature("struct Config {\n name: String,\n}");
|
||||
assert_eq!(s, "struct Config");
|
||||
// 声明以 ; 结尾
|
||||
let s = extract_signature("fn foo();");
|
||||
assert_eq!(s, "fn foo()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_calls_dedup() {
|
||||
let src = "fn a() { b(); b(); c(); }";
|
||||
let mut parser = Parser::new();
|
||||
parser.set_language(&Language::from(tree_sitter_rust::LANGUAGE)).unwrap();
|
||||
let tree = parser.parse(src, None).unwrap();
|
||||
let calls = collect_calls(tree.root_node(), src.as_bytes());
|
||||
// b 去重只出现一次
|
||||
assert!(calls.iter().any(|c| c == "b"));
|
||||
assert_eq!(calls.iter().filter(|c| *c == "b").count(), 1);
|
||||
assert!(calls.iter().any(|c| c == "c"));
|
||||
}
|
||||
|
||||
/// 多角度验证(用户要求:实现后看效果 + 多角度测试):
|
||||
/// 解析 devflow 真实源码 code_intel.rs 自身,验骨架态密度提升(设计目标:降一个量级)。
|
||||
/// 对比 read_symbol 骨架(单符号签名+calls)vs read_file 全文(整个文件所有符号)的体积。
|
||||
#[test]
|
||||
fn test_read_symbol_real_devflow_token_density() {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR"); // src-tauri/
|
||||
let path_str = format!("{}/src/commands/ai/code_intel.rs", manifest);
|
||||
let source = std::fs::read_to_string(&path_str)
|
||||
.expect("应能读到 devflow 自身 code_intel.rs");
|
||||
let full_bytes = source.len();
|
||||
|
||||
// 骨架态:解析自身 read_symbol 函数(只给该符号,非整个文件)
|
||||
let skeleton = read_symbol(&source, "h0", "code_intel.rs", "read_symbol", false, None);
|
||||
assert_ne!(skeleton["fallback"], true, "真实文件应解析出 read_symbol 符号");
|
||||
assert_eq!(skeleton["symbol"], "read_symbol");
|
||||
assert_eq!(skeleton["kind"], "function_item");
|
||||
let skeleton_str = serde_json::to_string(&skeleton).unwrap();
|
||||
let skeleton_bytes = skeleton_str.len();
|
||||
|
||||
// 密度断言:read_symbol 骨架(单符号)<< read_file 全文(整个文件)。
|
||||
// 正是治 prompt 爆的核心:LLM 要理解 read_symbol,工具只给该符号骨架,不给全文件。
|
||||
let ratio = full_bytes as f64 / skeleton_bytes.max(1) as f64;
|
||||
eprintln!(
|
||||
"[密度效果] code_intel.rs 全文 {} 字节 vs read_symbol 骨架 {} 字节 → 降 {:.1}x",
|
||||
full_bytes, skeleton_bytes, ratio
|
||||
);
|
||||
assert!(ratio > 5.0, "骨架密度应显著高于全文(降 >5x),实际 {:.1}x", ratio);
|
||||
|
||||
// full 态:完整定义体(语法边界 100% 准)
|
||||
let full_sym = read_symbol(&source, "h1", "code_intel.rs", "read_symbol", true, None);
|
||||
let content = full_sym["content"].as_str().expect("full 态应返 content");
|
||||
assert!(content.contains("pub fn read_symbol"));
|
||||
// calls 应含 read_symbol 内部调用的函数(grammar_for/extract_symbol/fallback 等)
|
||||
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
|
||||
assert!(!calls.is_empty(), "应提取出内部调用");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use df_ai::provider::{ChatMessage, ContentPart};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
@@ -30,7 +30,7 @@ use super::super::augmentation::build_augmentation_segment;
|
||||
use super::super::augmentation::sanitize;
|
||||
use super::super::conversation::save_conversation;
|
||||
use super::super::knowledge_inject::inject_knowledge_into_prompt;
|
||||
use super::super::prompt::build_system_prompt;
|
||||
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
|
||||
|
||||
use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
|
||||
@@ -137,6 +137,29 @@ fn span_to_mention_ref(span: &MentionSpanDto) -> Option<MentionRef> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 mention_spans 提取 (被@的项目 id 列表, 被@的任务 id 列表)。
|
||||
///
|
||||
/// 用于 [`build_system_prompt_with_excluded`] 的去重入参:被@的 project/task 已有 augmentation
|
||||
/// 精准投影,需从全局清单中排除以免同一实体两次入 prompt(skill/idea 不在清单内,无需排除)。
|
||||
///
|
||||
/// - `None` / 空 spans → 两个空 vec(行为退化为无去重的全貌清单,等同旧 build_system_prompt)。
|
||||
/// - 仅收集 kind == "project" / "task" 的 `ref_id`(idea/skill 不进项目/任务清单)。
|
||||
/// - 不去重(同实体被@多次理论上不会出现,即便出现 excluded 列表多次命中也只是 continue,无副作用)。
|
||||
fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<String>, Vec<String>) {
|
||||
let mut proj = Vec::new();
|
||||
let mut task = Vec::new();
|
||||
if let Some(spans) = spans.as_ref() {
|
||||
for span in spans {
|
||||
match span.kind.as_str() {
|
||||
"project" => proj.push(span.ref_id.clone()),
|
||||
"task" => task.push(span.ref_id.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(proj, task)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 发送 / 审批 / 控制
|
||||
// ============================================================
|
||||
@@ -259,7 +282,15 @@ pub async fn ai_is_generating(
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(session.conv_read(&target).map(|c| c.generating).unwrap_or(false))
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state==Generating(含 Compressed 派生态,
|
||||
// 压缩期间 loop 仍活跃),off 回退 generating bool(可回退)。双轨过渡:开关 off 等价旧行为零破坏。
|
||||
Ok(session.conv_read(&target).map(|c| {
|
||||
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// 发送消息并获取流式 AI 响应
|
||||
@@ -363,7 +394,11 @@ pub async fn ai_chat_send(
|
||||
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||||
// 去重:从 mention_spans 提取被@的 project/task id,传给 prompt 构建器从全局清单中排除
|
||||
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
|
||||
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
||||
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
@@ -457,11 +492,15 @@ pub async fn ai_approve(
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
|
||||
}
|
||||
let conv_id = approval.conversation_id.clone();
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
|
||||
// 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
drop(session);
|
||||
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
|
||||
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
|
||||
@@ -568,16 +607,22 @@ pub async fn ai_approve(
|
||||
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
|
||||
}
|
||||
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: id.clone(),
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id,
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
drop(session);
|
||||
|
||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||
@@ -769,16 +814,22 @@ pub async fn ai_authorize_dir(
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
||||
}
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
@@ -864,16 +915,22 @@ pub async fn ai_authorize_dir(
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
||||
}
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
result: result_val.clone(),
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_completed.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
|
||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
||||
id: tool_call_id.clone(),
|
||||
approved: true,
|
||||
conversation_id: conv_id.clone(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
if let Some(ref cid) = conv_id {
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
||||
}
|
||||
@@ -988,9 +1045,12 @@ pub async fn ai_chat_clear_context(
|
||||
// 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
|
||||
if protect_start == 0 {
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
|
||||
let ev = AiChatEvent::AiContextCleared {
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
|
||||
@@ -1004,9 +1064,12 @@ pub async fn ai_chat_clear_context(
|
||||
};
|
||||
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
|
||||
let ev = AiChatEvent::AiContextCleared {
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1041,9 +1104,12 @@ pub async fn ai_chat_compress_context(
|
||||
return Err("压缩正在进行中".to_string());
|
||||
}
|
||||
// emit 开始 + 置位防重入(成对释放,见下方所有出口)
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressing {
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conversation_id.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
conv.messages.set_compressing(true);
|
||||
|
||||
let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
@@ -1052,10 +1118,13 @@ pub async fn ai_chat_compress_context(
|
||||
conv.messages.set_compressing(false);
|
||||
let cid = session.active_conversation_id.clone();
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
conversation_id: cid,
|
||||
summary: String::new(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
|
||||
@@ -1075,10 +1144,13 @@ pub async fn ai_chat_compress_context(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary: String::new(),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1090,11 +1162,14 @@ pub async fn ai_chat_compress_context(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
error_type: Some(super::super::ErrorType::Auth),
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Err(format!("压缩失败: {}", e));
|
||||
}
|
||||
};
|
||||
@@ -1113,11 +1188,14 @@ pub async fn ai_chat_compress_context(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
error_type: Some(super::super::ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Err(format!("压缩失败: {}", e));
|
||||
}
|
||||
};
|
||||
@@ -1131,10 +1209,13 @@ pub async fn ai_chat_compress_context(
|
||||
conv.messages.set_compressing(false);
|
||||
}
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
|
||||
let ev = AiChatEvent::AiCompressed {
|
||||
conversation_id: Some(conv_id),
|
||||
summary,
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1341,19 +1422,24 @@ pub async fn ai_chat_force_send(
|
||||
|
||||
// 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
|
||||
if let Some(ref cid) = old_conv_id {
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
|
||||
// system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||||
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
|
||||
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
|
||||
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
|
||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
||||
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
|
||||
if !aug_seg.is_empty() {
|
||||
@@ -1409,7 +1495,15 @@ pub async fn ai_chat_stop(
|
||||
return Ok(());
|
||||
}
|
||||
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。
|
||||
let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false);
|
||||
// L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed),
|
||||
// off 回退 generating bool。双轨过渡:off 等价旧行为。
|
||||
let is_gen = session.conv_read(&target).map(|c| {
|
||||
if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED {
|
||||
c.conv_state.is_active()
|
||||
} else {
|
||||
c.generating
|
||||
}
|
||||
}).unwrap_or(false);
|
||||
if !is_gen {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1431,10 +1525,13 @@ pub async fn ai_chat_stop(
|
||||
conv.generating = false;
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
|
||||
conversation_id: Some(target),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return Ok(());
|
||||
}
|
||||
// 流式生成中:置位让目标 conv 的 loop 自行收尾
|
||||
@@ -1468,16 +1565,16 @@ pub async fn ai_chat_stop(
|
||||
if still_gen {
|
||||
session.conv(&target_owned).generating = false;
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let _ = app_handle.emit(
|
||||
"ai-chat-event",
|
||||
AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1550,12 +1647,15 @@ pub async fn ai_stop_loop(
|
||||
conv.generating = false;
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conversation_id),
|
||||
});
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
@@ -57,12 +57,18 @@ pub(crate) fn accumulate_tokens(old: Option<i64>, add: u32) -> Option<i64> {
|
||||
///
|
||||
/// 防 read_file 1MB 洞 / list_directory 大体量结果落库后每轮重发累积致 token 暴增
|
||||
/// (Sprint 19 实测单对话 in=115万 / 消息体 1.6MB)。仅作用于持久化视图,不污染内存真相源。
|
||||
pub(crate) const TRUNCATE_THRESHOLD: usize = 50_000;
|
||||
const TRUNCATE_HEAD: usize = 20_000;
|
||||
const TRUNCATE_TAIL: usize = 20_000;
|
||||
///
|
||||
/// B-260619-02:50KB → 8KB 收紧。治 GLM 1214 messages 非法根因——GLM anthropic 端点对
|
||||
/// 单条 tool_result content 限制更严(实测 ~10-20KB 即拒 1214),read_file 默认前 500 行源码
|
||||
/// 约 11-21KB,旧 50KB 阈值未触发→DB 存全量→reload/下轮重发→GLM 1214 整请求失败。
|
||||
/// 8KB 对齐 GLM 单条上限留余量;截断后 LLM 仍能据截断片段推理(优于 1214 直接拒收)。
|
||||
pub(crate) const TRUNCATE_THRESHOLD: usize = 8_192;
|
||||
const TRUNCATE_HEAD: usize = 3_072;
|
||||
const TRUNCATE_TAIL: usize = 3_072;
|
||||
|
||||
/// 落库前对超长 content 做截断(保留头尾各 ~20KB + 中段标注省略字符数)。
|
||||
/// 50KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
|
||||
/// 落库前对超长 content 做截断(保留头尾各 ~3KB + 中段标注省略字符数 + 总字节数)。
|
||||
/// 8KB 阈值以下原样返回(零开销);按字符而非字节切避免 UTF-8 切坏中文。
|
||||
/// 截断提示含原始字节数,让 LLM 明确感知非全文(防它误以为已看全)。
|
||||
pub(crate) fn truncate_for_persist(content: &str) -> String {
|
||||
let chars: Vec<char> = content.chars().collect();
|
||||
if chars.len() <= TRUNCATE_THRESHOLD {
|
||||
@@ -72,15 +78,15 @@ pub(crate) fn truncate_for_persist(content: &str) -> String {
|
||||
let tail: String = chars[chars.len() - TRUNCATE_TAIL..].iter().collect();
|
||||
let omitted = chars.len() - TRUNCATE_HEAD - TRUNCATE_TAIL;
|
||||
format!(
|
||||
"{}\n\n[...省略 {} 字符(已截断,完整内容仅在内存态可读)...]\n\n{}",
|
||||
head, omitted, tail
|
||||
"{}\n\n[...省略 {} 字符(已截断,原 {} 字节,仅显示前 8KB,完整内容仅在内存态可读)...]\n\n{}",
|
||||
head, omitted, content.len(), tail
|
||||
)
|
||||
}
|
||||
|
||||
/// 落库前对 `ChatMessage.parts` 做截断(F-260614-05 Phase 2a)。
|
||||
///
|
||||
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 50KB 才截)。
|
||||
/// - `Image` 片:base64 通常已 50KB+,落库前替换为占位 `Text` 片
|
||||
/// - `Text` 片:复用 `truncate_for_persist` 头尾截断语义(单 Text 片 > 8KB 才截)。
|
||||
/// - `Image` 片:base64 通常已 8KB+,落库前替换为占位 `Text` 片
|
||||
/// `<image: base64 已省略, 共 N 字节>`,避免大体量图把对话 JSON 撑爆
|
||||
/// (一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。原 Image 片仅在内存真相源(ContextManager)
|
||||
/// 保留,重发时仍带图——对齐现有「持久化视图不污染内存真相源」约定。
|
||||
@@ -149,9 +155,10 @@ pub(crate) async fn save_conversation(
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
|
||||
// 工具结果(content)超 50KB 时截断头尾各 ~20KB + 中段标注,防大体量结果(read_file 1MB 洞 /
|
||||
// list_directory 13782 项)落库后每轮重发累积致 token 暴增。仅影响持久化视图,不污染
|
||||
// 内存真相源(ContextManager)——build_for_request 仍读全量 messages。
|
||||
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
|
||||
// 标注(含原字节数),防大体量结果(read_file 1MB 洞 / list_directory 13782 项)落库后每轮重发
|
||||
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
|
||||
// 仍读全量 messages。
|
||||
//
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
|
||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
||||
@@ -161,7 +168,13 @@ pub(crate) async fn save_conversation(
|
||||
let mut session = session_arc.lock().await;
|
||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||
for m in &mut msgs {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
|
||||
// (实测 tool result read 大文件后落库 Invalid control character @pos3072)。
|
||||
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
@@ -354,9 +367,11 @@ mod tests {
|
||||
// 头尾各 20k 字符应在结果中
|
||||
assert!(result.starts_with(&"x".repeat(TRUNCATE_HEAD)));
|
||||
assert!(result.ends_with(&"x".repeat(TRUNCATE_TAIL)));
|
||||
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 51000 - 20000 - 20000 = 11000)
|
||||
// 中段标注存在 + 标注省略字符数(中段 = 总长 - 头 - 尾 = 9192 - 3072 - 3072 = 3048)
|
||||
assert!(result.contains("已截断"));
|
||||
assert!(result.contains("省略 11000 字符"));
|
||||
assert!(result.contains("省略 3048 字符"));
|
||||
// B-260619-02:截断提示含原字节数(9192 个 'x' = 9192 字节),让 LLM 感知非全文
|
||||
assert!(result.contains("原 9192 字节"));
|
||||
// 结果总长应远小于原长(20k 头 + 20k 尾 + 标注)
|
||||
assert!(result.chars().count() < TRUNCATE_THRESHOLD + 1000);
|
||||
}
|
||||
@@ -425,4 +440,68 @@ mod tests {
|
||||
fn truncate_parts_empty_returns_none() {
|
||||
assert_eq!(truncate_parts_for_persist(&[]), None);
|
||||
}
|
||||
|
||||
// ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ----------
|
||||
|
||||
/// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
|
||||
fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
|
||||
df_ai::provider::ChatMessage {
|
||||
id: None,
|
||||
role,
|
||||
content: content.to_string(),
|
||||
parts: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
status: None,
|
||||
reasoning_content: None,
|
||||
timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// save_conversation 的 content 截断决策片段(抽测 role 分支,避免起 DB/锁)。
|
||||
/// 对 tool role 跳过截断,其余 role 走截断。
|
||||
fn truncate_decision(m: &mut df_ai::provider::ChatMessage) {
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_role_large_content_not_truncated() {
|
||||
// tool result 是结构化 JSON,中段截断插裸换行+省略标记会破坏 JSON parse。
|
||||
// >8192(阈值)的 tool content 必须原样保留(不进 truncate_for_persist)。
|
||||
let json_like = format!(
|
||||
"{{\"file\":\"x.rs\",\"content\":\"{}\"}}",
|
||||
"a".repeat(TRUNCATE_THRESHOLD + 2000)
|
||||
);
|
||||
let original = json_like.clone();
|
||||
let mut m = make_msg(df_ai::provider::MessageRole::Tool, &json_like);
|
||||
truncate_decision(&mut m);
|
||||
// 关键断言:tool role 不截断,原样保留(长度与内容完全一致)
|
||||
assert_eq!(m.content.len(), original.len(), "tool role content 不应被截断");
|
||||
assert_eq!(m.content, original, "tool role content 原样保留");
|
||||
// 不含截断标记(证明未进 truncate_for_persist)
|
||||
assert!(!m.content.contains("已截断"), "tool role 不应含截断标注");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_role_large_content_still_truncated() {
|
||||
// 对照:assistant role 超阈值仍截断(回归保护)
|
||||
let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
|
||||
let mut m = make_msg(df_ai::provider::MessageRole::Assistant, &long);
|
||||
truncate_decision(&mut m);
|
||||
assert!(m.content.contains("已截断"), "assistant role 超阈值应截断");
|
||||
assert!(m.content.len() < long.len(), "assistant role 截断后应变短");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_role_short_content_unchanged() {
|
||||
// 短 tool content:即使进了 truncate_for_persist 也不会改(阈值以下),
|
||||
// 但此处验证 role 分支本身对短 tool content 也安全(原样保留)。
|
||||
let short = r#"{"ok":true,"rows":3}"#;
|
||||
let mut m = make_msg(df_ai::provider::MessageRole::Tool, short);
|
||||
truncate_decision(&mut m);
|
||||
assert_eq!(m.content, short);
|
||||
}
|
||||
}
|
||||
|
||||
313
src-tauri/src/commands/ai/event_bus.rs
Normal file
313
src-tauri/src/commands/ai/event_bus.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! 全局事件数据总线 — pub-sub 骨架(载荷 serde_json::Value 透传)
|
||||
//!
|
||||
//! 关联设计:docs/02-架构设计/专项设计/全局事件数据总线-2026-06-21.md
|
||||
//! 跨端透传:docs/02-架构设计/已编号方案/F-260622-01-跨端AIChat-Phase3联调设计-2026-06-22.md(§2.1 D2/D3)
|
||||
//!
|
||||
//! 本模块是 **统一事件数据总线(Unified Event Bus)** 的后端骨架,聚焦 **pub-sub**(发布订阅)
|
||||
//! 语义。背景:AI 事件经 `app_handle.emit("ai-chat-event", ...)` 散布在各处(55 处/7 文件),
|
||||
//! 无统一总线,跨模块直接耦合,阻碍跨端透传(df-tunnel)。
|
||||
//!
|
||||
//! ## 载荷决策(Phase3 D2 = 全 19 变体透传)
|
||||
//! 载荷用 `serde_json::Value`(即 AiChatEvent 序列化 JSON),**不用**强类型 enum 子集。
|
||||
//! 理由:跨端透传需 AiChatEvent 全 19 变体(含 ApprovalRequired/Compressed 等),早期骨架的
|
||||
//! AiBusEvent 8 变体强类型子集不足以覆盖,且总线在透传场景不该解析业务语义(传输层天职)。
|
||||
//! 改 Value 后:tunnel subscriber 收 Value 原样转发 miniapp,零变体丢失,零业务耦合。
|
||||
//!
|
||||
//! ## 本批范围(L3 骨架,纯新增无行为变化)
|
||||
//! - `EventBus` 结构(基于 tokio::sync::broadcast,容量 256,载荷 `serde_json::Value`)
|
||||
//! - `subscribe()` / `publish()` 基础接口
|
||||
//! - `EVENT_BUS_ENABLED` 开关(预留,默认 on —— 总线骨架自带,接入事件源时此开关控制是否经总线)
|
||||
//! - pub/sub 基础单元测试
|
||||
//!
|
||||
//! ## 不做(留后续批次)
|
||||
//! - 不接入 55 处 emit 点(批3 双写:publish + 原 app.emit 并行,关开关回退)
|
||||
//! - 不接 df-tunnel subscriber(Phase3 阶段2:tunnel 注册 subscriber,收 Value 转发 miniapp)
|
||||
//! - 不实现 request-reply / 流式 reply(设计阶段2/5,本批仅 pub-sub)
|
||||
//!
|
||||
//! ## 开关与兜底(渐进可回退)
|
||||
//! - `EVENT_BUS_ENABLED: bool`(默认 true):接入事件源后,关闭则 publish 静默丢弃(不影响原 emit 路径)。
|
||||
//! 骨架阶段未接入,无实际效果,预留作接入期的快速回退开关。
|
||||
//! - publish 返回 `usize`(接收者数量),调用方可忽略;无接收者时不报错(broadcast 语义)。
|
||||
//! - 慢消费者(broadcast 满):`send` 返 `Err(SendError)`,本骨架 `publish` 静默丢弃并返回 0,
|
||||
//! 不 panic(对齐设计待决策6「背压/限流」的保守默认,正式背压策略留后续)。
|
||||
//!
|
||||
//! ## 与 df-workflow EventBus 关系
|
||||
//! df-workflow 的 `EventBus`(`crates/df-workflow/src/eventbus.rs`)是工作流节点间专用总线
|
||||
//! (仅 `WorkflowEvent`),本总线是面向 AI 域的通用 pub-sub(透传 AiChatEvent 全量 Value)。
|
||||
//! AppState 分两个字段:`event_bus`(工作流)+ `ai_event_bus`(AI 域),互不干扰。
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::AiChatEvent;
|
||||
|
||||
// ============================================================
|
||||
// 开关(EVENT_BUS_ENABLED,预留,默认 on)
|
||||
//
|
||||
// 机制:接入事件源后,关闭则 EventBus::publish 静默丢弃事件(原 app.emit 路径不受影响,
|
||||
// 双写桥接由接入批负责,关闭总线仅丢总线副本)。骨架阶段未接入,开关预留作接入期快速回退。
|
||||
//
|
||||
// 兜底:即便开关误关,总线静默丢事件不影响原 emit 路径(AiChatEvent 经 app.emit 仍正常推送前端)。
|
||||
// ============================================================
|
||||
|
||||
/// 事件总线开关(预留,默认 on)。
|
||||
///
|
||||
/// 接入实际事件源后,`EventBus::publish` 在 `false` 时静默丢弃(返回 0),原 `app.emit`
|
||||
/// 路径不受影响。骨架阶段未接入,无实际效果。
|
||||
///
|
||||
/// dead_code 说明:本批为骨架,EVENT_BUS_ENABLED 暂无消费方(未接入 emit 点);
|
||||
/// 标 allow 保留作批3 接入期的快速回退开关(零调用方≠垃圾,预留保留)。
|
||||
#[allow(dead_code)]
|
||||
pub const EVENT_BUS_ENABLED: bool = true;
|
||||
|
||||
/// 默认事件通道容量(对齐 df-workflow EventBus 默认 256)。
|
||||
///
|
||||
/// 容量权衡:太小 → 慢消费者丢事件(订阅者消费不及时);太大 → 内存占用。
|
||||
/// 256 是 broadcast 常见默认值,覆盖典型 AI 流场景(文本片段 + 工具调用 + 心跳混合)。
|
||||
///
|
||||
/// dead_code 说明:骨架阶段 EventBus::new() 内联用字面量路径常量,但常量本身供
|
||||
/// 外部自定义容量场景引用,标 allow 保留(预留)。
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_BUS_CAPACITY: usize = 256;
|
||||
|
||||
// ============================================================
|
||||
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
|
||||
//
|
||||
// 选 broadcast 而非 mpsc:
|
||||
// - pub-sub 多订阅者(mpsc 单消费者不满足「多模块订阅同一事件」场景)
|
||||
// - broadcast 容量固定,慢消费者丢老事件而非阻塞发布者(对齐流式场景:宁可丢老 chunk 不阻塞 LLM 流)
|
||||
//
|
||||
// 载荷 serde_json::Value(Phase3 D2):透传 AiChatEvent 全 19 变体原样 JSON,subscriber(tunnel)
|
||||
// 收到后零解析转发 miniapp。总线不持业务类型知识(传输层天职),扩展 AiChatEvent 变体无需改总线。
|
||||
//
|
||||
// Clone 语义:broadcast::Sender 内部 Arc,clone 共享同一通道(多持有者 publish 到同一总线)。
|
||||
// 对齐设计 §架构「模块订阅 + 发布,无相互 import」—— 各模块持 clone 的 EventBus 即可 pub/sub。
|
||||
// ============================================================
|
||||
|
||||
/// AI 事件总线(pub-sub 域,载荷 `serde_json::Value`,L3 骨架)。
|
||||
///
|
||||
/// 基于 `tokio::sync::broadcast`,多订阅者多发布者共享同一通道。
|
||||
/// - `subscribe()`:订阅,返回 [`broadcast::Receiver`] recv `serde_json::Value`
|
||||
/// - `publish(payload)`:发布,所有活跃订阅者收到(broadcast 语义)
|
||||
///
|
||||
/// 载荷是 AiChatEvent 序列化后的 JSON(Phase3 D2 全 19 变体透传),subscriber 不解析业务语义。
|
||||
///
|
||||
/// 容量默认 256(`DEFAULT_BUS_CAPACITY`),可经 [`EventBus::with_capacity`] 自定义。
|
||||
///
|
||||
/// 兜底:
|
||||
/// - 无订阅者时 publish 不报错(broadcast 语义,事件丢弃)
|
||||
/// - 慢消费者(容量满)publish 返 0(静默丢老事件,不 panic)
|
||||
/// - `EVENT_BUS_ENABLED=false` 时 publish 静默丢弃(预留开关,接入期回退用)
|
||||
///
|
||||
/// dead_code 说明:骨架阶段 emit 点未双写(publish 无调用方);标 allow 保留作批3 接入
|
||||
/// (零调用方≠垃圾,总线核心结构,批3 接入即消费)。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 事件订阅者(broadcast::Receiver,serde_json::Value 接收端)。
|
||||
///
|
||||
/// dead_code 说明:骨架阶段无外部消费方(批3 接入 tunnel subscriber),标 allow 保留。
|
||||
#[allow(dead_code)]
|
||||
pub type EventSubscriber = broadcast::Receiver<serde_json::Value>;
|
||||
|
||||
// dead_code 说明(impl 块):骨架阶段 EventBus publish 无调用方(emit 点未双写)
|
||||
// (批3 接入 emit 点 + tunnel subscriber 后立即消费)。标 allow 覆盖 new/with_capacity/
|
||||
// subscribe/publish/subscriber_count 全部关联项的 never-used 警告。
|
||||
// 对齐零调用方原则:预留保留不盲删(批3 接入即消除)。
|
||||
#[allow(dead_code)]
|
||||
impl EventBus {
|
||||
/// 创建默认容量(256)的事件总线。
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_BUS_CAPACITY)
|
||||
}
|
||||
|
||||
/// 创建指定容量的事件总线。
|
||||
///
|
||||
/// capacity 为 0 会 panic(broadcast::channel 要求 capacity ≥ 1)。
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
let (sender, _) = broadcast::channel(capacity);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// 订阅事件总线,返回 Receiver。
|
||||
///
|
||||
/// 订阅后仅收到订阅时刻之后的 publish(历史事件不补发)。可多次 subscribe 获多个独立 Receiver,
|
||||
/// 每个 Receiver 各自维护消费进度(broadcast 多消费者语义)。
|
||||
pub fn subscribe(&self) -> EventSubscriber {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
/// 发布 AiChatEvent 序列化 JSON 到总线。
|
||||
///
|
||||
/// 返回值:成功送达的活跃订阅者数量(0 = 无订阅者或容量满丢老事件)。
|
||||
/// `EVENT_BUS_ENABLED=false` 时静默丢弃返回 0(预留开关,骨架阶段未接入)。
|
||||
///
|
||||
/// 注:broadcast::send 是同步方法(非 async),与 df-workflow EventBus::send 的 async 签名
|
||||
/// 不同 —— 本骨架 publish 同步返回更直接(broadcast::send 内部无 await 点),df-workflow 的
|
||||
/// async 签名是为对齐 trait 抽象,本总线无此约束故同步。
|
||||
pub fn publish(&self, payload: serde_json::Value) -> usize {
|
||||
if !EVENT_BUS_ENABLED {
|
||||
return 0;
|
||||
}
|
||||
// broadcast::send 返 Result<usize, SendError>:
|
||||
// Ok(n) = 送达 n 个活跃订阅者
|
||||
// Err(SendError(_)) = 无订阅者(事件丢弃)
|
||||
// 两种情况都不 panic,Err 视为 0(对齐「无订阅者静默丢弃」兜底)。
|
||||
self.sender.send(payload).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 便捷发布:接收强类型 AiChatEvent,内部序列化为 Value 透传(D2 全 19 变体透传)。
|
||||
///
|
||||
/// 调用方传 AiChatEvent(业务侧已有强类型对象),本方法内部 `serde_json::to_value`
|
||||
/// 序列化为 Value 后调 [`publish`](Self::publish)。供 emit 点双写(publish + app.emit),
|
||||
/// 经 EventBus 透传给 tunnel subscriber(阶段2 接入)。
|
||||
///
|
||||
/// 序列化失败兜底返 0(丢弃,对齐 publish 静默丢弃语义;AiChatEvent 序列化稳定不触发,防御性)。
|
||||
pub fn publish_event(&self, event: AiChatEvent) -> usize {
|
||||
match serde_json::to_value(&event) {
|
||||
Ok(v) => self.publish(v),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前活跃订阅者数量(broadcast::receiver_count)。
|
||||
///
|
||||
/// 诊断/监控用:接入期可观测订阅者是否到位。骨架阶段无实际调用,预留作可观测性扩展。
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.sender.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — pub/sub 基础语义(载荷 serde_json::Value)
|
||||
//
|
||||
// 覆盖:
|
||||
// 1. subscribe 后 publish,receiver 收到 Value(基础 pub-sub)
|
||||
// 2. 多订阅者都收到(broadcast 多消费者)
|
||||
// 3. 无订阅者时 publish 不 panic(兜底)
|
||||
// 4. publish 返回值 = 活跃订阅者数量
|
||||
// 5. AiChatEvent 形态 Value 透传(含 type tag,模拟真实 AiChatEvent JSON)
|
||||
//
|
||||
// 不覆盖(留后续批次接入时):
|
||||
// - 慢消费者容量满丢老事件(需灌满容量场景,接入期补)
|
||||
// - 跨模块解耦实际效果(需接入真实 emit 点,批3)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 基础 pub-sub:subscribe 后 publish,receiver 收到 Value 原样。
|
||||
#[tokio::test]
|
||||
async fn test_basic_pub_sub() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
// 模拟 AiChatEvent::AiTextDelta 序列化 JSON(type tag discriminator)
|
||||
let payload = serde_json::json!({
|
||||
"type": "text_delta",
|
||||
"conversation_id": "conv-1",
|
||||
"delta": "hello"
|
||||
});
|
||||
let delivered = bus.publish(payload.clone());
|
||||
assert_eq!(delivered, 1, "publish 应送达 1 个订阅者");
|
||||
|
||||
let received = rx.recv().await.expect("receiver 应收到事件");
|
||||
assert_eq!(received, payload, "Value 应原样透传(零解析)");
|
||||
assert_eq!(received["type"], "text_delta");
|
||||
}
|
||||
|
||||
/// 多订阅者都收到(broadcast 多消费者语义)。
|
||||
#[tokio::test]
|
||||
async fn test_multiple_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx1 = bus.subscribe();
|
||||
let mut rx2 = bus.subscribe();
|
||||
|
||||
let payload = serde_json::json!({"type": "heartbeat", "conversation_id": null});
|
||||
let delivered = bus.publish(payload.clone());
|
||||
assert_eq!(delivered, 2, "publish 应送达 2 个订阅者");
|
||||
|
||||
// 两个 receiver 各自收到(独立消费进度)
|
||||
let r1 = rx1.recv().await.expect("rx1 应收到");
|
||||
let r2 = rx2.recv().await.expect("rx2 应收到");
|
||||
assert_eq!(r1["type"], "heartbeat");
|
||||
assert_eq!(r2["type"], "heartbeat");
|
||||
}
|
||||
|
||||
/// 无订阅者时 publish 不 panic,返回 0(兜底)。
|
||||
#[test]
|
||||
fn test_publish_no_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
// 无 subscribe 直接 publish
|
||||
let delivered = bus.publish(serde_json::json!({"type": "error", "error": "no one listening"}));
|
||||
assert_eq!(delivered, 0, "无订阅者 publish 应返回 0");
|
||||
}
|
||||
|
||||
/// publish 返回值 = 活跃订阅者数量(subscriber_count 对齐)。
|
||||
#[test]
|
||||
fn test_delivered_count_matches_subscribers() {
|
||||
let bus = EventBus::new();
|
||||
assert_eq!(bus.subscriber_count(), 0);
|
||||
|
||||
let _rx1 = bus.subscribe();
|
||||
assert_eq!(bus.subscriber_count(), 1);
|
||||
|
||||
let _rx2 = bus.subscribe();
|
||||
assert_eq!(bus.subscriber_count(), 2);
|
||||
|
||||
// delivered 应等于 subscriber_count
|
||||
let delivered = bus.publish(serde_json::json!({"type": "agent_round", "round": 1}));
|
||||
assert_eq!(delivered, bus.subscriber_count());
|
||||
}
|
||||
|
||||
/// EventBus Clone 后共享同一通道(同一总线多持有者)。
|
||||
#[tokio::test]
|
||||
async fn test_clone_shares_channel() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
// 经 clone 的实例 publish,原实例订阅的 receiver 也应收到(共享通道)
|
||||
bus_clone.publish(serde_json::json!({
|
||||
"type": "completed",
|
||||
"total_tokens": 100,
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 50,
|
||||
"conversation_id": null
|
||||
}));
|
||||
|
||||
let received = rx.recv().await.expect("clone 共享通道,receiver 应收到");
|
||||
assert_eq!(received["type"], "completed");
|
||||
}
|
||||
|
||||
/// 全 19 变体形态 Value 均可透传(不限于子集,验证 D2 全透传不丢变体)。
|
||||
#[test]
|
||||
fn test_full_variants_passthrough() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
// 覆盖早期 AiBusEvent 子集外的变体(ApprovalRequired/Compressed 等),证明全透传
|
||||
let events = vec![
|
||||
serde_json::json!({"type": "approval_required", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "compressed", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "dir_auth_required", "conversation_id": "c1"}),
|
||||
serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}),
|
||||
];
|
||||
for event in &events {
|
||||
bus.publish(event.clone());
|
||||
}
|
||||
for expected in &events {
|
||||
let received = rx.try_recv().expect("应收到每个变体");
|
||||
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! - [`prompt`] — 系统提示词构建
|
||||
//! - [`provider_pool`] — F-260614-04 多 Provider 负载均衡池选择(纯逻辑)
|
||||
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
|
||||
//! - [`event_bus`] — L3 全局事件数据总线 pub-sub 骨架(broadcast,渐进第一步,未接入 emit)
|
||||
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
|
||||
//! - [`knowledge_inject`] — 知识库注入 + 提炼
|
||||
//! - [`augmentation`] — 上下文增强
|
||||
@@ -28,19 +29,24 @@
|
||||
pub mod agentic;
|
||||
pub mod augmentation;
|
||||
pub mod audit;
|
||||
pub mod code_intel;
|
||||
pub mod commands;
|
||||
pub mod compress;
|
||||
pub mod conversation;
|
||||
pub mod event_bus;
|
||||
pub mod http;
|
||||
pub mod knowledge_inject;
|
||||
pub mod prompt;
|
||||
pub mod provider_pool;
|
||||
pub mod remote_bridge;
|
||||
pub mod secret;
|
||||
pub mod skills;
|
||||
pub mod stream_recv;
|
||||
pub mod title;
|
||||
pub mod tool_registry;
|
||||
|
||||
use agentic::conv_state::ConvState;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -50,6 +56,8 @@ use serde::{Deserialize, Serialize};
|
||||
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
|
||||
use df_ai::context::ContextManager;
|
||||
use df_ai::context::ContextConfig;
|
||||
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
|
||||
use df_ai::provider::ChatMessage;
|
||||
|
||||
// ============================================================
|
||||
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
|
||||
@@ -140,6 +148,24 @@ pub enum AiChatEvent {
|
||||
error_type: Option<ErrorType>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
|
||||
///
|
||||
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
|
||||
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
|
||||
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
|
||||
///
|
||||
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
|
||||
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
|
||||
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
|
||||
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
|
||||
/// → **孤立响应气泡**。此事件填补该缺口。
|
||||
///
|
||||
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
|
||||
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
|
||||
AiUserMessage {
|
||||
message: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// Agent 循环新一轮(前端需新建 assistant 消息)
|
||||
AiAgentRound { round: u32, conversation_id: Option<String> },
|
||||
/// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」)
|
||||
@@ -175,6 +201,125 @@ pub enum AiChatEvent {
|
||||
AiCompressing { conversation_id: Option<String> },
|
||||
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。
|
||||
AiCompressed { conversation_id: Option<String>, summary: String },
|
||||
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
///
|
||||
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
||||
/// 「失败就问用户」——LLM 不可靠,故用结构化事件 + 前端求助卡(机制优先 prompt 说教)。
|
||||
///
|
||||
/// 字段语义:
|
||||
/// - `reason`:求助原因(死循环/环境未知/权限不足等可读摘要,前端直接展示)
|
||||
/// - `context`:已试情况(失败次数/最近错误 sample/卡在哪),供用户判断如何介入
|
||||
/// - `options`:建议选项(如 [换思路/授权路径/人工接管]),前端渲染为按钮供用户选
|
||||
/// - `conversation_id`:多对话路由(与其他事件一致)
|
||||
///
|
||||
/// 与 AiApprovalRequired 区分:审批=工具执行前确认;求助=AI 主动「我搞不定」。
|
||||
/// 与 AiMaxRoundsReached 区分:达 max 是被动截断;求助是主动识别卡点。
|
||||
/// 兜底:开关 CIRCUIT_BREAKER_ENABLED=false 或求助消费方未接 → 后端保留 AiError 终态分支兜底。
|
||||
AiHelpRequired {
|
||||
reason: String,
|
||||
context: String,
|
||||
options: Vec<String>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
|
||||
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 双职责:
|
||||
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
|
||||
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
|
||||
///
|
||||
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
|
||||
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
|
||||
AiConversationList {
|
||||
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
|
||||
conversations: Vec<ConvSummary>,
|
||||
},
|
||||
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
|
||||
///
|
||||
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
|
||||
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
|
||||
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
|
||||
AiMessageHistory {
|
||||
/// 对话 ID
|
||||
conversation_id: String,
|
||||
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
|
||||
messages: Vec<ChatMessage>,
|
||||
},
|
||||
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
|
||||
///
|
||||
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
|
||||
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
|
||||
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
|
||||
///
|
||||
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生,
|
||||
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。
|
||||
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
|
||||
/// 供前端 union discriminator 直接使用。
|
||||
AiConvStateChanged {
|
||||
conv_state: ConvState,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
|
||||
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
|
||||
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
|
||||
///
|
||||
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
|
||||
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
|
||||
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
|
||||
AiSkillList {
|
||||
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
|
||||
skills: Vec<crate::commands::ai::skills::SkillInfo>,
|
||||
},
|
||||
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
|
||||
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
|
||||
///
|
||||
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
|
||||
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
|
||||
///
|
||||
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
|
||||
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
|
||||
/// #[tauri::command] 返回值,必然 impl Serialize)。
|
||||
AiEntityList {
|
||||
/// 项目全量列表(list_active,deleted_at IS NULL)
|
||||
projects: Vec<df_storage::models::ProjectRecord>,
|
||||
/// 任务全量列表(list_active 等价,未删任务)
|
||||
tasks: Vec<df_storage::models::TaskRecord>,
|
||||
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
|
||||
ideas: Vec<df_storage::models::IdeaRecord>,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-#95 跨端会话列表同步:会话摘要结构
|
||||
// ============================================================
|
||||
|
||||
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
|
||||
///
|
||||
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
|
||||
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
|
||||
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
|
||||
///
|
||||
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
|
||||
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConvSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
|
||||
pub last_message: Option<String>,
|
||||
/// 最近更新时间(ms 数值)
|
||||
pub updated_at: Option<i64>,
|
||||
/// 创建时间(ms 数值,可选展示)
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -604,6 +749,10 @@ pub struct PerConvState {
|
||||
pub messages: ContextManager,
|
||||
/// 是否正在生成
|
||||
pub generating: bool,
|
||||
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。
|
||||
pub conv_state: ConvState,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -654,6 +803,7 @@ impl PerConvState {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
generating: false,
|
||||
conv_state: ConvState::Idle,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
iteration_used: 0,
|
||||
|
||||
@@ -31,24 +31,31 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前运行环境信息(注入 system prompt,供 LLM 写日期/命令时参考)
|
||||
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
|
||||
/// L1 环境感知(治 kms seq26-52 引号地狱):教 AI 避命令行 -c 内联,复杂脚本写文件执行。
|
||||
///
|
||||
/// - 日期:LLM 无法自行获取当前日期,不注入则写文件日期全靠猜(常从项目已有文件名模式推断出错)
|
||||
/// - 操作系统:LLM 写 shell 命令需知道平台(dir vs ls、路径分隔符等)
|
||||
/// - 日期:LLM 无法自行获取,不注入则写文件日期全靠猜
|
||||
/// - OS + shell:平台对应 shell(Windows=PowerShell / Linux·macOS=bash),AI 写命令需对齐
|
||||
/// - 执行姿势:复杂脚本(多行/含引号/$变量)写 .ps1/.py 文件用 `powershell -File`/`python` 执行,
|
||||
/// 避免命令行 -c 内联(cmd/PowerShell 引号转义易出错,kms 实证 20+ 次失败)
|
||||
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
|
||||
///
|
||||
/// 两项合计约 25 token,消除一整类系统性错误。
|
||||
fn env_info_line() -> String {
|
||||
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
|
||||
fn env_profile_line() -> String {
|
||||
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
let (os, shell, interp) = if cfg!(target_os = "windows") {
|
||||
("Windows", "PowerShell", "python(无 python3)、node")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
("macOS", "bash/zsh", "python3、node")
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
("Linux", "bash", "python3、node")
|
||||
} else {
|
||||
"Unknown"
|
||||
("Unknown", "sh", "python3、node")
|
||||
};
|
||||
format!("当前日期: {today} | 运行环境: {os}\n\n")
|
||||
format!(
|
||||
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
|
||||
执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
|
||||
@@ -113,9 +120,34 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
/// 非 build_system_prompt 职责 —— 两套并行:全局清单(全貌)+ augmentation(用户本次引用)。
|
||||
///
|
||||
/// 注入克制:仅各取最近 20 条,防 context 膨胀。
|
||||
///
|
||||
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
|
||||
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
|
||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||
build_system_prompt_with_excluded(state, lang, &[], &[]).await
|
||||
}
|
||||
|
||||
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
|
||||
///
|
||||
/// 与 [`build_system_prompt`] 的差异(机制而非说教):
|
||||
/// 1. **去重**:被 @ 的 project/task 已由 augmentation 层精准投影(含 path/正文),清单中
|
||||
/// 再现会造成同一实体两次入 prompt(实测 build f64dee94 会话 list_projects 8 次 /
|
||||
/// list_tasks 20 次)。此处把 `excluded_project_ids`/`excluded_task_ids` 命中的条目
|
||||
/// 从全局清单循环中跳过,被@实体只保留 augmentation 一次。
|
||||
/// 2. **注明语**:清单末尾按语言追加一行,告知 LLM 「项目已全部列出无需再调 list_projects」/
|
||||
/// 「任务仅最近 20 条,全量查询请用 list_tasks」,从机制层降低工具与清单重复调用
|
||||
/// (而非在行为准则里反复说教)。
|
||||
///
|
||||
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
|
||||
/// - 注入克制策略不变:仍各 take(20)。
|
||||
pub(crate) async fn build_system_prompt_with_excluded(
|
||||
state: &AppState,
|
||||
lang: &str,
|
||||
excluded_project_ids: &[String],
|
||||
excluded_task_ids: &[String],
|
||||
) -> String {
|
||||
let (prefix, proj_label, task_label) = system_prompt_parts(lang);
|
||||
let mut prompt = env_info_line();
|
||||
let mut prompt = env_profile_line();
|
||||
prompt.push_str(prefix);
|
||||
|
||||
// 附加当前数据上下文
|
||||
@@ -123,9 +155,15 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
||||
if !projects.is_empty() {
|
||||
prompt.push_str(proj_label);
|
||||
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
|
||||
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
|
||||
for p in projects.iter().take(20) {
|
||||
if excluded_project_ids.iter().any(|id| id == &p.id) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
|
||||
}
|
||||
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
|
||||
prompt.push_str(&projects_listed_note(lang, 20));
|
||||
}
|
||||
}
|
||||
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
|
||||
@@ -133,15 +171,48 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
||||
if let Ok(tasks) = state.tasks.list_active().await {
|
||||
if !tasks.is_empty() {
|
||||
prompt.push_str(task_label);
|
||||
// 去重:被 @ 的任务跳过(同项目去重机理)
|
||||
for tk in tasks.iter().take(20) {
|
||||
if excluded_task_ids.iter().any(|id| id == &tk.id) {
|
||||
continue;
|
||||
}
|
||||
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description));
|
||||
}
|
||||
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
|
||||
prompt.push_str(&tasks_listed_note(lang));
|
||||
}
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// 项目清单尾部注明语(中/英)。
|
||||
///
|
||||
/// 机制层降重复:直接告知「已全部列出」,LLM 知道再调 list_projects 是冗余。
|
||||
/// `shown` 取实际注入条数上限 20(避免传运态 list_active 长度,只描述清单本身)。
|
||||
fn projects_listed_note(lang: &str, shown: usize) -> String {
|
||||
match lang {
|
||||
"en" => format!(
|
||||
"(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n",
|
||||
shown
|
||||
),
|
||||
_ => format!(
|
||||
"(以上为活跃项目清单,无需重复调用 list_projects)\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务清单尾部注明语(中/英)。
|
||||
///
|
||||
/// 机制层降重复:明确「仅最近 20 条 + 全量查询走 list_tasks」,避免 LLM 误以为清单即全量
|
||||
/// 而反复调 list_tasks 校验(实测单会话 20 次)。
|
||||
fn tasks_listed_note(lang: &str) -> String {
|
||||
match lang {
|
||||
"en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(),
|
||||
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文压缩 system prompt 模板(F-15 §4.2,纯函数无 IO)。
|
||||
///
|
||||
/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留
|
||||
@@ -284,4 +355,20 @@ mod tests {
|
||||
assert!(en.contains("topic words"));
|
||||
assert!(en.contains("breaks the context"));
|
||||
}
|
||||
|
||||
// 项目/任务清单注明语(中/英)存在
|
||||
#[test]
|
||||
fn listed_notes_present_zh_en() {
|
||||
let zh_proj = projects_listed_note("zh-CN", 20);
|
||||
assert!(zh_proj.contains("无需重复调用 list_projects"));
|
||||
let en_proj = projects_listed_note("en", 20);
|
||||
assert!(en_proj.contains("no need to call list_projects"));
|
||||
|
||||
let zh_task = tasks_listed_note("zh-CN");
|
||||
assert!(zh_task.contains("仅显示最近 20 条任务"));
|
||||
assert!(zh_task.contains("list_tasks"));
|
||||
let en_task = tasks_listed_note("en");
|
||||
assert!(en_task.contains("20 most recent tasks"));
|
||||
assert!(en_task.contains("list_tasks"));
|
||||
}
|
||||
}
|
||||
|
||||
1057
src-tauri/src/commands/ai/remote_bridge.rs
Normal file
1057
src-tauri/src/commands/ai/remote_bridge.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use futures::StreamExt;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -226,10 +226,13 @@ pub(crate) async fn stream_llm(
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
full_text.push_str(&chunk.delta);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: chunk.delta,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
@@ -326,9 +329,12 @@ pub(crate) async fn stream_llm(
|
||||
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
|
||||
_ = heartbeat.tick() => {
|
||||
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiHeartbeat {
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。
|
||||
// stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。
|
||||
|
||||
@@ -1021,9 +1021,15 @@ fn register_file_tools(
|
||||
let more = (skip + page.len()) < line_count;
|
||||
(page.join("\n"), Some(skip), more)
|
||||
} else {
|
||||
// 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量)
|
||||
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
||||
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
||||
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
|
||||
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||||
const DEFAULT_PREVIEW_LINES: usize = 500;
|
||||
let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect();
|
||||
let limit = args["limit"].as_u64()
|
||||
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
|
||||
.min(2000) as usize;
|
||||
let page: Vec<&str> = content.lines().take(limit).collect();
|
||||
let more = line_count > page.len();
|
||||
(page.join("\n"), None, more)
|
||||
};
|
||||
@@ -1036,6 +1042,50 @@ fn register_file_tools(
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false)]),
|
||||
RiskLevel::Low,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let allowed_dirs = allowed_dirs.clone();
|
||||
Box::pin(async move {
|
||||
let snap = allowed_dirs.read().await.clone();
|
||||
let resolved = resolve_workspace_path_with_allowed(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
&snap,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?;
|
||||
let full = args["full"].as_bool().unwrap_or(false);
|
||||
let kind_hint = args["kind"].as_str();
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
let metadata = file.metadata().await
|
||||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||||
if metadata.len() > 1_048_576 {
|
||||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||||
}
|
||||
let file_hash = compute_file_hash(&metadata);
|
||||
let mut content = String::new();
|
||||
if let Err(e) = file.read_to_string(&mut content).await {
|
||||
if e.kind() == std::io::ErrorKind::InvalidData {
|
||||
return Ok(serde_json::json!({
|
||||
"path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
|
||||
"fallback": true, "reason": "binary",
|
||||
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
|
||||
}));
|
||||
}
|
||||
anyhow::bail!("读取文件失败: {}", e);
|
||||
}
|
||||
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
|
||||
Ok(crate::commands::ai::code_intel::read_symbol(
|
||||
&content, &file_hash, path, symbol, full, kind_hint,
|
||||
))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||||
@@ -1108,8 +1158,10 @@ fn register_file_tools(
|
||||
Err(_) => None, // 不存在(新建)
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
|
||||
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
|
||||
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
|
||||
if !snap.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
@@ -1415,7 +1467,8 @@ fn register_file_tools(
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||||
if !snap.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
@@ -1552,7 +1605,8 @@ fn register_file_tools(
|
||||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||||
let to_target = std::path::Path::new(to_path);
|
||||
if let Some(parent) = to_target.parent() {
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||||
if !snap.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
@@ -1835,6 +1889,140 @@ fn register_file_tools(
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 环境感知 (Low risk, L1 agent 元能力层) ──
|
||||
// detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。
|
||||
// 返回 JSON:{ os, default_shell, python_path, node_path }。
|
||||
//
|
||||
// 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!),
|
||||
// AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。
|
||||
// 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测,
|
||||
// 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。
|
||||
//
|
||||
// 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后,
|
||||
// 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。
|
||||
//
|
||||
// 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。
|
||||
// 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。
|
||||
//
|
||||
// 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测,
|
||||
// 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。
|
||||
// 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。
|
||||
//
|
||||
// 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因,
|
||||
// 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。
|
||||
registry.register(
|
||||
"detect_environment",
|
||||
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
|
||||
df_ai::ai_tools::object_schema(vec![]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||||
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
|
||||
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
|
||||
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
|
||||
let default_shell = if cfg!(target_os = "windows") {
|
||||
"PowerShell"
|
||||
} else {
|
||||
"bash"
|
||||
};
|
||||
|
||||
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
|
||||
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
|
||||
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
|
||||
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
|
||||
Err(_) => true, // 未设 = 默认开
|
||||
};
|
||||
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let (python_path, node_path) = if probe_enabled {
|
||||
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
|
||||
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
|
||||
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
|
||||
let py = if cfg!(target_os = "windows") {
|
||||
probe_executable("python", "where python", &mut errors).await
|
||||
} else {
|
||||
let p3 = probe_executable("python3", "which python3", &mut errors).await;
|
||||
if p3.is_some() {
|
||||
p3
|
||||
} else {
|
||||
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
|
||||
probe_executable("python", "which python", &mut errors).await
|
||||
}
|
||||
};
|
||||
let node = if cfg!(target_os = "windows") {
|
||||
probe_executable("node", "where node", &mut errors).await
|
||||
} else {
|
||||
probe_executable("node", "which node", &mut errors).await
|
||||
};
|
||||
(py, node)
|
||||
} else {
|
||||
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"os": os,
|
||||
"default_shell": default_shell,
|
||||
"python_path": python_path,
|
||||
"node_path": node_path,
|
||||
"probe_enabled": probe_enabled,
|
||||
"errors": errors,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
|
||||
///
|
||||
/// 执行 `cmd`(如 "which python3" / "where node")取 stdout 首行(trim 后)作为路径。
|
||||
/// 失败(命令不存在/解释器未装/超时/退出码非 0)记 `error` 到 `errors` 并返回 None。
|
||||
///
|
||||
/// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。
|
||||
/// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop),
|
||||
/// shell_type 用 default(Windows=PowerShell / Unix=sh)。
|
||||
async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec<String>) -> Option<String> {
|
||||
let request = ShellRequest {
|
||||
command: cmd.to_string(),
|
||||
working_dir: None,
|
||||
env: HashMap::new(),
|
||||
// 8s 超时:which/where 通常 <1s,8s 足够且不拖垮会话。超时视为不可用(记错返 None)。
|
||||
timeout_secs: Some(8),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
match execute(request).await {
|
||||
Ok(result) => {
|
||||
// 退出码非 0:which/where 未找到目标(如 python 未装)→ 正常情况,记错返 None
|
||||
if !matches!(result.exit_code, Some(0)) {
|
||||
errors.push(format!(
|
||||
"{}: 探测命令退出码 {:?}({})",
|
||||
label, result.exit_code, cmd
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let first_line = result.stdout.lines().next().map(|s| s.trim().to_string());
|
||||
match first_line {
|
||||
Some(p) if !p.is_empty() => Some(p),
|
||||
_ => {
|
||||
errors.push(format!("{}: 探测输出为空({})", label, cmd));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{}: 探测失败({}): {}", label, cmd, e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||
@@ -2262,7 +2450,7 @@ mod tests {
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 30 个工具(18 data + 11 file + 1 http),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 31 个工具(18 data + 12 file + 1 http),且工具名集合稳定。
|
||||
///
|
||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -2275,17 +2463,18 @@ mod tests {
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
|
||||
// 总量基线:30(18 data + 11 file + 1 http)。拆分前后必须一致。
|
||||
// 总量基线:31(18 data + 12 file + 1 http)。拆分前后必须一致。
|
||||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
30,
|
||||
"工具总数应为 30(18 data + 11 file + 1 http),实际 {}", registry.len()
|
||||
31,
|
||||
"工具总数应为 31(18 data + 12 file + 1 http),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 18 个(持 db):CRUD/状态机/工作流
|
||||
// file 层 11 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep
|
||||
// file 层 12 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (18) ──
|
||||
@@ -2295,12 +2484,13 @@ mod tests {
|
||||
"run_workflow", "delete_task", "create_idea",
|
||||
"delete_project", "restore_project", "purge_project",
|
||||
"list_trash", "get_project_count", "get_task_count",
|
||||
// ── file 层 (11) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621)
|
||||
// ── file 层 (12) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1)
|
||||
"read_file", "list_directory", "write_file",
|
||||
"patch_file", "file_info", "append_file",
|
||||
"delete_file", "rename_file", "search_files", "run_command",
|
||||
"grep",
|
||||
"grep", "detect_environment",
|
||||
// ── http 层 (1) ──
|
||||
"http_request",
|
||||
];
|
||||
@@ -2475,6 +2665,73 @@ mod tests {
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// read_file 分页 limit 尊重测试(BUG-260623-01 回归守护)
|
||||
//
|
||||
// read_file handler 无 offset 分支旧实现固定 take(500),忽略 LLM 传入的 limit。
|
||||
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||||
// 修复:无 offset 时也读 args[limit](对齐有 offset 分支语义),未传默认 500。
|
||||
//
|
||||
// 经完整 AiToolRegistry.execute 调用 read_file handler(端到端覆盖 offset/limit 分支),
|
||||
// 而非内联重复分页逻辑——避免测试与实现两处分页代码漂移(实现改测试不红)。
|
||||
// ============================================================
|
||||
|
||||
/// 造 600 行文件,无 offset + limit=15 → returned_lines=15(limit 生效,旧 bug 返 500)。
|
||||
#[tokio::test]
|
||||
async fn test_read_file_no_offset_respects_limit() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_readfile_limit_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
// 600 行(>500 默认,确保 limit=15 与默认 500 行为可区分;15 < 500 锁定 limit 被读)
|
||||
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||||
let file = tmp.join("big.txt");
|
||||
fs::write(&file, body).unwrap();
|
||||
// persistent 存词法形态(对齐 is_authorized:candidate 经 strip_verbatim 归一词法去 \\?\ 前缀;
|
||||
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
|
||||
let mut persistent = std::collections::HashSet::new();
|
||||
persistent.insert(tmp.clone());
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
|
||||
assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// 造 600 行文件,无 offset 无 limit → 默认 500 行(DEFAULT_PREVIEW_LINES)。
|
||||
#[tokio::test]
|
||||
async fn test_read_file_no_offset_no_limit_defaults_500() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_readfile_default_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||||
let file = tmp.join("big.txt");
|
||||
fs::write(&file, body).unwrap();
|
||||
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
|
||||
let mut persistent = std::collections::HashSet::new();
|
||||
persistent.insert(tmp.clone());
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
|
||||
assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// patch_file 三模式共用底层测试(F-260617-01)
|
||||
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
||||
|
||||
@@ -8,7 +8,7 @@ use tauri::State;
|
||||
use df_ai::provider::LlmProvider;
|
||||
use df_types::types::{new_id, Priority};
|
||||
use df_ideas::capture::Idea;
|
||||
use df_storage::crud::is_unique_constraint_err;
|
||||
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
|
||||
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord};
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -32,16 +32,29 @@ fn default_priority() -> i32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// 列出灵感,可选按 status 过滤(指定状态走 query 走白名单索引列,否则全量)
|
||||
/// 列出灵感。
|
||||
///
|
||||
/// **双路径向后兼容**(F-260621-02):
|
||||
/// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走
|
||||
/// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。
|
||||
/// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。
|
||||
/// - 两者都不传 → 等价全量(`list_by_query` 空 query 走默认 created_at DESC,与 list_all 等价)。
|
||||
///
|
||||
/// `query` 优先于 `status`(二者同传时以 query 为准,避免重复过滤语义冲突)。
|
||||
#[tauri::command]
|
||||
pub async fn list_ideas(
|
||||
state: State<'_, AppState>,
|
||||
status: Option<String>,
|
||||
query: Option<IdeaQuery>,
|
||||
) -> Result<Vec<IdeaRecord>, String> {
|
||||
match status {
|
||||
Some(s) => state.ideas.query("status", &s).await.map_err(err_str),
|
||||
None => state.ideas.list_all().await.map_err(err_str),
|
||||
}
|
||||
let q = match query {
|
||||
Some(q) => q,
|
||||
None => IdeaQuery {
|
||||
status,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
state.ideas.list_by_query(&q).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 列出指定灵感的评估历史(version DESC,最新版本在前)。
|
||||
|
||||
@@ -17,6 +17,7 @@ use df_project::scan::{
|
||||
collect_sample, detect_stack, discover_projects, extract_description,
|
||||
normalize_path, DiscoveredProject,
|
||||
};
|
||||
use df_storage::crud::ProjectQuery;
|
||||
use df_storage::models::ProjectRecord;
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -38,10 +39,27 @@ pub struct CreateProjectInput {
|
||||
pub stack: Option<String>,
|
||||
}
|
||||
|
||||
/// 列出未删除项目(过滤回收站)
|
||||
/// 列出未删除项目(过滤回收站)。
|
||||
///
|
||||
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
|
||||
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
|
||||
#[tauri::command]
|
||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>, String> {
|
||||
state.projects.list_active().await.map_err(err_str)
|
||||
pub async fn list_projects(
|
||||
state: State<'_, AppState>,
|
||||
query: Option<ProjectQuery>,
|
||||
) -> Result<Vec<ProjectRecord>, String> {
|
||||
match query {
|
||||
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active,
|
||||
// 与历史行为完全等价(列表上 list_by_query 空也等价,但保留 list_active 分支明示向后兼容契约)。
|
||||
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|
||||
|| q.order_by.is_some()
|
||||
|| q.limit.is_some()
|
||||
|| q.offset.is_some() =>
|
||||
{
|
||||
state.projects.list_by_query(q).await.map_err(err_str)
|
||||
}
|
||||
_ => state.projects.list_active().await.map_err(err_str),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建项目,返回完整记录
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
use df_types::types::{new_id, TaskStatus};
|
||||
use df_storage::crud::TaskQuery;
|
||||
use df_storage::models::TaskRecord;
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -30,18 +31,41 @@ fn default_priority() -> i32 {
|
||||
2 // medium — 新任务默认中优先级(非 high),符合常识
|
||||
}
|
||||
|
||||
/// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤。
|
||||
/// 列出未删除任务(deleted_at IS NULL)。
|
||||
///
|
||||
/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。
|
||||
/// 有 project_id 时走 list_active_by_project(SQL 下推 WHERE deleted_at IS NULL AND
|
||||
/// project_id = ?,避免旧 list_active 全表扫 + 内存 retain 的 N×M 热点);
|
||||
/// 无 project_id 时 fallback list_active(列全部未删任务)。
|
||||
/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。
|
||||
/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id)
|
||||
/// 必须等价改造前的全量行为,零破坏。
|
||||
///
|
||||
/// F-260621-02 查询维度补全(机制优先 prompt 说教):
|
||||
/// - 优先走 `query`(`TaskQuery` 多维动态 WHERE):status(P1 下沉)/ keyword(P2 LIKE)
|
||||
/// /project_id/priority/assignee/order_by/limit/offset 任意组合。
|
||||
/// - 旧调用方仍可直接传 `project_id`(单维度),此时走 list_active_by_project
|
||||
/// (SQL 下推,命中 idx_tasks_project_id),保持等价行为。
|
||||
/// - query 与 project_id 同时传时:query 优先(其内含 project_id 维度,更全),project_id 忽略。
|
||||
/// - 均不传时:全量未删任务(等价改造前 list_active 行为)。
|
||||
///
|
||||
/// status 值合法性兜底:TaskStatus::is_valid 拦截非法值(拼写错/越界),非法值返回 Err
|
||||
/// (与 update_task 的 status 校验一致),不静默返回空结果误导调用方。
|
||||
#[tauri::command]
|
||||
pub async fn list_tasks(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
query: Option<TaskQuery>,
|
||||
) -> Result<Vec<TaskRecord>, String> {
|
||||
if let Some(q) = &query {
|
||||
// status 值兜底校验(非法值早 Err,不进 DB 层)
|
||||
if let Some(status) = &q.status {
|
||||
if !TaskStatus::is_valid(status) {
|
||||
return Err(format!(
|
||||
"非法 status 值 {:?},合法值: {:?}",
|
||||
status,
|
||||
TaskStatus::valid_values()
|
||||
));
|
||||
}
|
||||
}
|
||||
return state.tasks.list_by_query(q).await.map_err(err_str);
|
||||
}
|
||||
// 旧调用方:单维度 project_id(SQL 下推,fallback 等价改造前行为)
|
||||
let tasks = match project_id {
|
||||
Some(pid) => state
|
||||
.tasks
|
||||
|
||||
@@ -6,6 +6,8 @@ mod state;
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use state::AppState;
|
||||
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
|
||||
use df_tunnel::TunnelClient;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -86,6 +88,135 @@ pub fn run() {
|
||||
});
|
||||
});
|
||||
|
||||
// Phase3 跨端隧道集成:tunnel connect relay + subscriber 透传 ai_event_bus → miniapp
|
||||
// + on_command 下行路由(miniap 指令 → remote_bridge → Tauri command)。
|
||||
// spawn 后台 task:device_id/relay_url 解析(持久 KV,无则默认)→ subscriber 透传
|
||||
// → on_command 回调注册 → connect(失败非阻断,可后续手动重连)。
|
||||
let tunnel_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = tunnel_app.state::<AppState>();
|
||||
|
||||
// device_id:KV 取,无则生成 UUID v4 落库(持久,relay 按 device_id 路由配对)。
|
||||
let device_id = match state.settings.get("device_id").await {
|
||||
Ok(Some(id)) if !id.is_empty() => id,
|
||||
_ => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let _ = state.settings.set("device_id", &id).await;
|
||||
id
|
||||
}
|
||||
};
|
||||
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
|
||||
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
|
||||
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
|
||||
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
|
||||
let relay_url = state
|
||||
.settings
|
||||
.get("relay_url")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|raw| {
|
||||
serde_json::from_str::<String>(&raw)
|
||||
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
|
||||
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
|
||||
let token = "devflow-relay-default-token".to_string();
|
||||
|
||||
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
|
||||
let mut rx = state.ai_event_bus.subscribe();
|
||||
let tunnel_for_sub = state.tunnel.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tracing::info!("[tunnel-sub] subscriber task 启动,透传 ai_event_bus → relay");
|
||||
while let Ok(value) = rx.recv().await {
|
||||
if let Err(e) = tunnel_for_sub.send_raw_event(value).await {
|
||||
tracing::warn!("[tunnel-sub] send_raw_event 失败(可能未连接): {}", e);
|
||||
}
|
||||
}
|
||||
tracing::info!("[tunnel-sub] subscriber task 退出(rx 关闭)");
|
||||
});
|
||||
|
||||
// on_command 回调:miniapp 下行指令 → remote_bridge 路由 Tauri command。
|
||||
// CommandHandler = Arc<dyn Fn(Value) -> BoxFuture<'static, ()> + Send + Sync>
|
||||
// (tunnel.rs:45)。回调返 BoxFuture,Box::pin 构造 + 类型标注对齐签名。
|
||||
//
|
||||
// 借用处理:`handle_remote_command(payload, app, state)` 的 state 是
|
||||
// `State<'_, AppState>`(newtype `&AppState`),借用 app。若先 `let state =
|
||||
// app.state()` 再 move app 入函数,E0505(借用的 app 被 move)。
|
||||
// 正解:同一表达式内 `app.clone()` move clone、`app.state()` 借用原 app,
|
||||
// 原 app 不 move(仅被 state 借用到调用结束),借用检查通过。
|
||||
let cmd_app = tunnel_app.clone();
|
||||
let on_command: df_tunnel::tunnel::CommandHandler = std::sync::Arc::new(
|
||||
move |payload: serde_json::Value| {
|
||||
let app = cmd_app.clone();
|
||||
Box::pin(async move {
|
||||
crate::commands::ai::remote_bridge::handle_remote_command(
|
||||
payload,
|
||||
app.clone(),
|
||||
app.state::<AppState>(),
|
||||
)
|
||||
.await;
|
||||
})
|
||||
as std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = ()> + Send>,
|
||||
>
|
||||
},
|
||||
);
|
||||
|
||||
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
|
||||
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
|
||||
match state
|
||||
.tunnel
|
||||
.connect(&relay_url, &device_id, &token, on_command.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
"[tunnel] 连接 relay 成功 device_id={} url={}",
|
||||
device_id,
|
||||
relay_url
|
||||
),
|
||||
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
|
||||
}
|
||||
|
||||
// ── supervisor:断线自动重连(根治 device 离线) ──
|
||||
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
|
||||
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
|
||||
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
|
||||
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
|
||||
let mut backoff = std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if !state.tunnel.is_connected() {
|
||||
tracing::info!(
|
||||
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
|
||||
relay_url,
|
||||
device_id
|
||||
);
|
||||
match state
|
||||
.tunnel
|
||||
.connect(&relay_url, &device_id, &token, on_command.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
|
||||
backoff = std::time::Duration::from_secs(2);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
"[tunnel-supervisor] 重连失败,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -366,6 +366,16 @@ pub struct AppState {
|
||||
pub resolvers: Arc<ResolverRegistry>,
|
||||
/// AI 会话状态
|
||||
pub ai_session: Arc<Mutex<AiSession>>,
|
||||
/// L3 AI 事件总线(pub-sub 域,独立于工作流 event_bus)。
|
||||
///
|
||||
/// 批2b:总线入 AppState 就绪(基建就位)。emit 点双写 + 前端订阅**留批3**:
|
||||
/// 现阶段无后端订阅者(工作流独立域)+ 前端经 ai-chat-event 已收 → 全双写空转,
|
||||
/// 待真实消费者(跨端透传/多模块订阅)出现再针对性双写(渐进接入,机制优先)。
|
||||
///
|
||||
/// dead_code:批2b 仅入 AppState,emit 点未双写(publish 无调用方);
|
||||
/// 标 allow 保留作批3 接入(零调用方≠垃圾,预留保留)。
|
||||
#[allow(dead_code)]
|
||||
pub ai_event_bus: crate::commands::ai::event_bus::EventBus,
|
||||
// ── 知识库 ──
|
||||
/// 知识库 Repo
|
||||
pub knowledge: KnowledgeRepo,
|
||||
@@ -402,6 +412,12 @@ pub struct AppState {
|
||||
/// workspace_root 始终在白名单(向后兼容)。
|
||||
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
|
||||
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
// ── 跨端隧道(Phase3 Layer1)──
|
||||
/// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp
|
||||
/// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析
|
||||
/// device_id/relay_url 后 connect(失败非阻断,可后续手动重连)。
|
||||
/// Arc 包裹:setup task(subscriber + on_command 回调)与 AppState 共享同一客户端。
|
||||
pub tunnel: std::sync::Arc<df_tunnel::WsTunnelClient>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
@@ -649,6 +665,10 @@ impl AppState {
|
||||
ai_messages: AiMessageRepo::new(&db),
|
||||
ai_tool_executions: AiToolExecutionRepo::new(&db),
|
||||
ai_session: Arc::new(Mutex::new(AiSession::new())),
|
||||
// L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。
|
||||
ai_event_bus: crate::commands::ai::event_bus::EventBus::new(),
|
||||
// Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect
|
||||
tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()),
|
||||
knowledge: KnowledgeRepo::new(&db),
|
||||
knowledge_events: KnowledgeEventsRepo::new(&db),
|
||||
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"identifier": "top.1216.devflow",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord } from './types'
|
||||
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord, IdeaQuery } from './types'
|
||||
|
||||
export const ideaApi = {
|
||||
/** 列出灵感,可选按 status 过滤(draft/pending_review/promoted 等) */
|
||||
list(status?: string): Promise<IdeaRecord[]> {
|
||||
return invoke('list_ideas', { status: status ?? null })
|
||||
/**
|
||||
* 列出灵感。
|
||||
*
|
||||
* **双签名向后兼容**(F-260621-02):
|
||||
* - `list(status?)`:旧签名,仅状态过滤(等价 `list({ status })`)。
|
||||
* - `list(query?)`:新签名,多条件(status/keyword/order_by/limit/offset)走后端 WHERE。
|
||||
*
|
||||
* 参数为空 → 后端返回全量(created_at DESC,与旧 list_all 等价)。
|
||||
*/
|
||||
list(queryOrStatus?: IdeaQuery | string): Promise<IdeaRecord[]> {
|
||||
if (typeof queryOrStatus === 'string') {
|
||||
return invoke('list_ideas', { status: queryOrStatus ?? null, query: null })
|
||||
}
|
||||
return invoke('list_ideas', { status: null, query: queryOrStatus ?? null })
|
||||
},
|
||||
|
||||
create(input: CreateIdeaInput): Promise<IdeaRecord> {
|
||||
|
||||
@@ -46,9 +46,29 @@ export interface ImportBatchResult {
|
||||
items: ImportBatchItemResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目列表查询条件(F-260621-02 P2/P3)。
|
||||
* 全可选;不传(或全 undefined)= 当前全量行为(deleted_at IS NULL + created_at DESC)。
|
||||
* 对齐后端 df_storage::crud::ProjectQuery。
|
||||
*/
|
||||
export interface ProjectQuery {
|
||||
/** 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词) */
|
||||
keyword?: string
|
||||
/** 排序:格式 "col" / "col asc" / "col desc",默认 created_at desc(列名白名单) */
|
||||
order_by?: string
|
||||
/** 返回上限(后端钳制 [0,200],undefined = 不加 LIMIT 全量) */
|
||||
limit?: number
|
||||
/** 偏移量(undefined = 0,配合 limit 分页) */
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export const projectApi = {
|
||||
list(): Promise<ProjectRecord[]> {
|
||||
return invoke('list_projects')
|
||||
/**
|
||||
* 列出未删除项目。
|
||||
* F-260621-02:可选 query(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。
|
||||
*/
|
||||
list(query?: ProjectQuery): Promise<ProjectRecord[]> {
|
||||
return invoke('list_projects', { query })
|
||||
},
|
||||
|
||||
create(input: CreateProjectInput): Promise<ProjectRecord> {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { TaskRecord, CreateTaskInput } from './types'
|
||||
import type { TaskRecord, CreateTaskInput, TaskQuery } from './types'
|
||||
|
||||
export const taskApi = {
|
||||
list(projectId?: string): Promise<TaskRecord[]> {
|
||||
return invoke('list_tasks', { projectId: projectId ?? null })
|
||||
/**
|
||||
* 列出任务。
|
||||
*
|
||||
* **双签名向后兼容**(F-260621-02):
|
||||
* - `list(projectId?)`:旧签名,仅项目过滤(等价 `list({ project_id: projectId })`)。
|
||||
* - `list(query?)`:新签名,多条件(status/keyword/project_id/priority/assignee/...)走后端 WHERE。
|
||||
*
|
||||
* 参数为空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
|
||||
*/
|
||||
list(queryOrProjectId?: TaskQuery | string): Promise<TaskRecord[]> {
|
||||
if (typeof queryOrProjectId === 'string') {
|
||||
return invoke('list_tasks', { projectId: queryOrProjectId ?? null, query: null })
|
||||
}
|
||||
return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null })
|
||||
},
|
||||
|
||||
get(id: string): Promise<TaskRecord> {
|
||||
|
||||
@@ -34,6 +34,23 @@ export interface CreateIdeaInput {
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵感多条件查询入参(F-260621-02,对齐后端 IdeaQuery)。
|
||||
*
|
||||
* 所有字段可选;全空 → 后端返回全量(created_at DESC)。
|
||||
* - `status`:状态精确匹配(后端 WHERE,取代前端 filter)
|
||||
* - `keyword`:title/description LIKE %kw%(后端 LIKE,取代前端 filter)
|
||||
* - `order_by`:白名单 created_at/updated_at/priority/status/score(后端白名单校验防注入)
|
||||
* - `limit`/`offset`:分页(limit 钳制上限 200)
|
||||
*/
|
||||
export interface IdeaQuery {
|
||||
status?: string | null
|
||||
keyword?: string | null
|
||||
order_by?: string | null
|
||||
limit?: number | null
|
||||
offset?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵感评估历史记录(后端 list_idea_evaluations 返回)。
|
||||
* 每次评估(启发式/LLM/降级)落一行,version 递增,按 version DESC 返回。
|
||||
@@ -152,6 +169,32 @@ export interface CreateTaskInput {
|
||||
idea_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务多条件查询入参(F-260621-02,对齐后端 TaskQuery)。
|
||||
*
|
||||
* 所有字段可选;全空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
|
||||
* - `project_id`:项目精确匹配(命中 idx_tasks_project_id)
|
||||
* - `status`:状态精确匹配(P1 下沉,命中 idx_tasks_status,取代前端 filter)
|
||||
* - `priority`:优先级精确匹配(0=critical..3=low)
|
||||
* - `assignee`:负责人精确匹配
|
||||
* - `keyword`:title/description LIKE %kw%(P2,取代前端 filter)
|
||||
* - `order_by`:白名单 created_at/updated_at/priority/status(后端白名单校验防注入),恒 DESC
|
||||
* - `limit`/`offset`:分页(limit 钳制上限 500)
|
||||
*
|
||||
* 注:project_id/priority/assignee/order_by/limit/offset 为基建就绪,当前视图仅用
|
||||
* status(P1)+keyword(P2);P3 排序分页 UI 待数据量增长。
|
||||
*/
|
||||
export interface TaskQuery {
|
||||
project_id?: string | null
|
||||
status?: string | null
|
||||
priority?: number | null
|
||||
assignee?: string | null
|
||||
keyword?: string | null
|
||||
order_by?: string | null
|
||||
limit?: number | null
|
||||
offset?: number | null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工作流
|
||||
// ============================================================
|
||||
@@ -287,6 +330,21 @@ export interface ModelConfig {
|
||||
*/
|
||||
export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||
|
||||
/**
|
||||
* L2 统一状态机 — 对话生命周期状态字面量(对齐后端 ConvState serde rename_all=snake_case,
|
||||
* crates/df-ai/.../agentic/conv_state.rs)。5 态:
|
||||
* - idle:空闲,可接新请求
|
||||
* - generating:生成中(含审批挂起期,审批是 loop 内暂停点非独立态)
|
||||
* - stopping:停中(用户已点停止,loop 收尾中,瞬态)
|
||||
* - error:出错(用户可重试回 generating)
|
||||
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
|
||||
*
|
||||
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发
|
||||
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为
|
||||
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。
|
||||
*/
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
/** AI 聊天事件(后端 emit 的结构,conversation_id 用于多对话流式路由) */
|
||||
export type AiChatEvent = ({
|
||||
type: 'AiTextDelta'; delta: string
|
||||
@@ -315,6 +373,11 @@ export type AiChatEvent = ({
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
// F-260622-02 跨端用户消息同步:远程(miniapp)发来的用户消息,后端 route_send_message
|
||||
// 放行后 publish_event 广播。桌面 useAiEvents 收到后补 user 气泡(桌面本地 sendMessage 是
|
||||
// 乐观渲染不发此事件)。handleEvent 去重防双气泡(末条已是同 content user 跳过)。
|
||||
type: 'AiUserMessage'; message: string
|
||||
} | {
|
||||
type: 'AiAgentRound'; round: number
|
||||
} | {
|
||||
@@ -326,6 +389,20 @@ export type AiChatEvent = ({
|
||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||
} | {
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
||||
// 字段对齐后端 AiChatEvent::AiHelpRequired:reason(原因摘要)/context(已试情况)/options(建议选项)。
|
||||
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
|
||||
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
|
||||
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
|
||||
} | {
|
||||
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
|
||||
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
|
||||
// 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识
|
||||
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时
|
||||
// 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。
|
||||
type: 'AiConvStateChanged'; conv_state: ConvState
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
</div>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
|
||||
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
|
||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
@@ -136,6 +140,7 @@ import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import MessageList from './ai/MessageList.vue'
|
||||
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
|
||||
168
src/components/Paginator.vue
Normal file
168
src/components/Paginator.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<!--
|
||||
通用分页器(F-260621-02 P3 分页 UI 基建)。
|
||||
纯客户端切片分页:父级传 total(总数) + v-model:page(当前页,1 起) +
|
||||
v-model:pageSize(每页条数),本组件只管 UI,不做数据请求(数据量 ~15 前瞻基建)。
|
||||
|
||||
向后兼容铁律:pageSize=0 → 不分页(全量),整组件隐藏,等价改造前行为。
|
||||
用户从下拉选具体 N → 触发分页(切换 page 重置到第 1 页由父级 watch 控制)。
|
||||
-->
|
||||
<div v-if="pageSize > 0 && totalPages > 0" class="paginator">
|
||||
<div class="page-size-group">
|
||||
<span class="paginator-label">{{ $t('common.pagination.perPage') }}</span>
|
||||
<select
|
||||
class="page-size-select"
|
||||
:value="pageSize"
|
||||
@change="onPageSizeChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<span class="paginator-total">
|
||||
{{ $t('common.pagination.total', { n: total }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="page-nav">
|
||||
<button
|
||||
class="page-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="goTo(page - 1)"
|
||||
>{{ $t('common.pagination.prev') }}</button>
|
||||
|
||||
<button
|
||||
v-for="p in pageNumbers"
|
||||
:key="p"
|
||||
class="page-btn page-num"
|
||||
:class="{ active: p === page }"
|
||||
@click="goTo(p)"
|
||||
>{{ p }}</button>
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goTo(page + 1)"
|
||||
>{{ $t('common.pagination.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 数据总数(父级传当前过滤后的总数) */
|
||||
total: number
|
||||
/** 每页条数;0 = 不分页(整组件隐藏,等价旧行为) */
|
||||
pageSize: number
|
||||
/** 当前页码(1 起) */
|
||||
page: number
|
||||
/** 每页条数候选(默认 10/20/50) */
|
||||
pageSizeOptions?: number[]
|
||||
}>(), {
|
||||
pageSizeOptions: () => [10, 20, 50],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number]
|
||||
'update:pageSize': [size: number]
|
||||
}>()
|
||||
|
||||
// 总页数:pageSize=0 已被 v-if 拦截,此处保证 pageSize ≥ 1。
|
||||
const totalPages = computed(() => {
|
||||
if (props.pageSize <= 0) return 0
|
||||
return Math.max(1, Math.ceil(props.total / props.pageSize))
|
||||
})
|
||||
|
||||
// 简单页码序列:数据量小,全列(1..totalPages),不做省略窗口(7 页内可读)。
|
||||
// 若数据量未来增长到几十页,再加窗口折叠逻辑。
|
||||
const pageNumbers = computed(() => {
|
||||
const arr: number[] = []
|
||||
for (let i = 1; i <= totalPages.value; i++) arr.push(i)
|
||||
return arr
|
||||
})
|
||||
|
||||
function goTo(p: number) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
emit('update:page', p)
|
||||
}
|
||||
|
||||
function onPageSizeChange(val: string) {
|
||||
const n = Number(val)
|
||||
if (!Number.isFinite(n) || n < 0) return
|
||||
emit('update:pageSize', n)
|
||||
// 切换每页条数后页码可能越界,重置到第 1 页(父级也可自行 watch 处理)
|
||||
emit('update:page', 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paginator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: var(--df-gap-grid, 16px);
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
.page-size-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.paginator-label { white-space: nowrap; }
|
||||
|
||||
.page-size-select {
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paginator-total { color: var(--df-text-dim); }
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 28px;
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.paginator { justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -46,9 +46,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||
@@ -299,6 +300,34 @@ function scrollToFirstPending(): void {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):监听 pending 审批卡到达事件,
|
||||
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
|
||||
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
|
||||
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
|
||||
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
|
||||
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
|
||||
let _unlistenPending: UnlistenFn | null = null
|
||||
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
|
||||
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
|
||||
let _disposed = false
|
||||
onMounted(async () => {
|
||||
const fn = await listen('ai-pending-arrived', () => {
|
||||
// nextTick 等 pending_approval 类挂到 DOM 后再查(scrollToFirstPending 查 .ai-tool-card--pending_approval)。
|
||||
// 多个 ToolCardList 实例(每条 AI 消息一个)各自 listen,querySelector 只命中自身 root 内的
|
||||
// pending 卡,非含 pending 卡的实例查 null no-op,天然过滤不串扰。
|
||||
nextTick(() => scrollToFirstPending())
|
||||
})
|
||||
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
|
||||
if (_disposed) { fn(); return }
|
||||
_unlistenPending = fn
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
_disposed = true
|
||||
_unlistenPending?.()
|
||||
_unlistenPending = null
|
||||
})
|
||||
|
||||
defineExpose({ collapseInactive, scrollToFirstPending })
|
||||
|
||||
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */
|
||||
|
||||
@@ -84,8 +84,26 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="store.state.streaming"
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
@@ -119,6 +137,7 @@ import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -237,6 +256,28 @@ async function onDrop(e: DragEvent): Promise<void> {
|
||||
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
|
||||
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
|
||||
|
||||
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
|
||||
//
|
||||
// 三态映射(对齐后端 ConvState 语义):
|
||||
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
|
||||
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
|
||||
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
|
||||
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
|
||||
//
|
||||
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
|
||||
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
|
||||
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
|
||||
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
|
||||
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
const convId = store.state.activeConversationId
|
||||
const cs = getConvState(convId)
|
||||
if (cs === 'stopping') return 'stopping'
|
||||
if (cs === 'error') return 'retry'
|
||||
if (cs === 'generating' || cs === 'compressed') return 'stop'
|
||||
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
|
||||
return store.state.streaming ? 'stop' : 'idle'
|
||||
})
|
||||
|
||||
// ── 技能 `/` 联想 ──
|
||||
const skillOpen = ref(false)
|
||||
const skillIndex = ref(0)
|
||||
@@ -912,6 +953,18 @@ defineExpose({
|
||||
.ai-send-btn--stop:hover {
|
||||
background: #d63a3f;
|
||||
}
|
||||
/* L2 停止中态:disabled 灰显(防重复点),复用 :disabled 通用规则(opacity 0.4) */
|
||||
.ai-send-btn--stopping {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* L2 停止失败可重试态:橙色边框提示(区别于红色停止),hover 加深 */
|
||||
.ai-send-btn--retry {
|
||||
background: #e5484d;
|
||||
color: #fff;
|
||||
}
|
||||
.ai-send-btn--retry:hover {
|
||||
background: #d63a3f;
|
||||
}
|
||||
.ai-input-hint {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
|
||||
105
src/components/ai/HelpRequiredCard.vue
Normal file
105
src/components/ai/HelpRequiredCard.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断后展示的操作卡。
|
||||
触发:后端断路器连续同类失败达阈值 → emit AiHelpRequired(已 guard.reset,loop 终止)→
|
||||
useAiEvents handleLifecycleEvent 翻 pendingHelp(模块级 ref)→ 本卡渲染。
|
||||
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
||||
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
||||
<div class="ai-help-required-options">
|
||||
<button
|
||||
v-for="opt in helpActive.options"
|
||||
:key="opt"
|
||||
class="ai-help-required-btn"
|
||||
@click="handleSelectOption(opt)"
|
||||
>{{ opt }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
||||
|
||||
// toast 经 emit 转父(保持单一 toast 源,与 MaxRoundsCard 一致)
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前挂起的求助(有值即后端发过 AiHelpRequired)。pendingHelp 由 useAiEvents 翻:
|
||||
// AiHelpRequired 设值;AiCompleted/AiError 清值(重新发送或错误中断后求助卡消失)。
|
||||
//
|
||||
// helpActive 既做守卫(挂起会话===当前展示会话)又做模板取值源:返回 PendingHelp 非空或 null,
|
||||
// 模板 v-if="helpActive" 取真值后 Vue 类型收窄为非 null,可直接访问 .reason/.context/.options。
|
||||
// 显示守卫:仅当挂起求助属当前展示会话时显(F-09 多会话并发不串台)。
|
||||
// 注:不依赖 streaming——求助事件后端已 guard.reset(generating=false),若依赖 isGenerating
|
||||
// 会导致卡片不显(对齐 MaxRoundsCard F-260620 去 streaming 依赖的根治理)。
|
||||
const helpActive = computed(() => {
|
||||
const p = pendingHelp.value
|
||||
if (p && p.conversationId === store.state.activeConversationId) return p
|
||||
return null
|
||||
})
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
||||
function handleSelectOption(opt: string): void {
|
||||
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
||||
pendingHelp.value = null
|
||||
emit('toast', { msg: t('aiChat.helpRequiredAck'), type: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
|
||||
.ai-help-required {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-help-required-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-help-required-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-help-required-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-help-required-btn {
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-help-required-btn:hover { filter: brightness(1.1); }
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingMaxRounds } from '../../composables/ai/useAiEvents'
|
||||
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { aiApi } from '../../api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -47,6 +47,20 @@ const isViewingGenerating = computed(() =>
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
|
||||
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
|
||||
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
|
||||
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
|
||||
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
|
||||
// 理论不并存,此处宽放以防边界)。
|
||||
const isViewingGeneratingState = computed(() => {
|
||||
const cs = getConvState(store.state.activeConversationId)
|
||||
if (cs === 'generating' || cs === 'compressed') return true
|
||||
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
|
||||
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
|
||||
return isViewingGenerating.value
|
||||
})
|
||||
|
||||
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
||||
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
||||
@@ -56,7 +70,7 @@ const maxRoundsActing = ref(false)
|
||||
const showMaxRoundsCard = computed(() =>
|
||||
!!pendingMaxRounds.value
|
||||
&& pendingMaxRounds.value === store.state.activeConversationId
|
||||
&& isViewingGenerating.value,
|
||||
&& isViewingGeneratingState.value,
|
||||
)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
|
||||
@@ -425,7 +425,19 @@ watch(() => store.state.currentText, (text) => {
|
||||
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
|
||||
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
|
||||
if (store.state.streaming && text) scheduleStreamParse(text)
|
||||
else onContentChange()
|
||||
else {
|
||||
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):currentText 归零(新轮 AiAgentRound
|
||||
// 清空 / 生成结束)时,同步清上一轮流式分块缓存 streamingBlocks + lastStreamText。
|
||||
// 原:streaming watch(:435)仅在 streaming 翻 false 时清;但 agent 多轮连续生成时
|
||||
// streaming 保持 true,新轮 AiAgentRound push 新空气泡 + currentText='',streamingBlocks
|
||||
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
|
||||
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
|
||||
if (!text) {
|
||||
streamingBlocks.value = []
|
||||
lastStreamText = ''
|
||||
}
|
||||
onContentChange()
|
||||
}
|
||||
})
|
||||
watch(mdReady, (ready) => {
|
||||
if (ready && store.state.streaming && store.state.currentText) {
|
||||
@@ -844,6 +856,17 @@ watch(() => store.state.activeConversationId, () => {
|
||||
wasNearBottom = true
|
||||
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
|
||||
isFollowingBottom = true
|
||||
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
|
||||
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
|
||||
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
|
||||
// 清 currentText 但实例级 streamingBlocks 未同步;② restoreGeneratingState(useAiWindow)
|
||||
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
|
||||
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
|
||||
if (streamingBlocks.value.length) {
|
||||
streamingBlocks.value = []
|
||||
lastStreamText = ''
|
||||
}
|
||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||
})
|
||||
|
||||
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
||||
@@ -1008,7 +1031,7 @@ defineExpose({
|
||||
<!-- 文本内容(流式或固定,Markdown 渲染) -->
|
||||
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
||||
<div
|
||||
v-if="item.msg.content || (isLastAi(item.msg) && store.state.currentText)"
|
||||
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
|
||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
|
||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<template>
|
||||
<!-- ═══ 高级设置(AI 自动执行 / 日志级别)═══ -->
|
||||
<!-- ═══ 高级设置(AI 自动执行范围 / 日志级别)═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>{{ $t('settings.sectionAdvanced') }}</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<!-- F-#97:三档 low/medium/all。low=仅低风险自动(等价旧 autoExecute=false);
|
||||
medium=含中风险自动;all=全自动无审批(完全 AI 接管,选时二次确认) -->
|
||||
<SettingRow ref="rowAutoExecute" :label="$t('settings.labelAutoExecute')" :desc="$t('settings.descAutoExecute')">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="settings.autoExecute" @change="markSaved('autoExecute')" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<select v-model="settings.autoExecuteMode" class="setting-select" @change="onAutoModeChange">
|
||||
<option value="low">{{ $t('settings.autoModeLow') }}</option>
|
||||
<option value="medium">{{ $t('settings.autoModeMedium') }}</option>
|
||||
<option value="all">{{ $t('settings.autoModeAll') }}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
|
||||
<select v-model="settings.logLevel" class="setting-select" @change="markSaved('logLevel')">
|
||||
<select v-model="settings.logLevel" class="setting-select" @change="markSaved">
|
||||
<option value="error">Error</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
@@ -20,35 +23,130 @@
|
||||
</select>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
|
||||
<Transition name="confirm">
|
||||
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">{{ confirmState.msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, useTemplateRef } from 'vue'
|
||||
import { reactive, watch, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
// ============================================================
|
||||
// 高级域 — AI 自动执行开关 / 日志级别
|
||||
// 拆自 GeneralPanel,忠实迁移原逻辑:原 GeneralPanel 这两项为 session 内 reactive
|
||||
// (无 appSettings 持久化/watch),此处保持一致(无 watch/IPC),避免阶段2 引入行为变更。
|
||||
// 阶段5:SettingRow 封装行布局,控件变更后调 markSaved 显"已保存"
|
||||
// 后续若需持久化,单独建改进项。
|
||||
// 高级域 — AI 自动执行范围(三档) / 日志级别
|
||||
// F-#97(2026-06-22):原 autoExecute boolean checkbox → autoExecuteMode select 三档
|
||||
// (low/medium/all),KV 持久化 df-ai-auto-execute-mode,默认 low。
|
||||
// low 必须等价旧行为(autoExecute=false 仅 Low 风险自动):后端 audit/mod.rs 按 mode
|
||||
// 判定 Med/High 是否走自动分支,low 时 Med/High 仍进审批块,行为与旧完全一致。
|
||||
// all(完全接管)选时二次确认:防误选致无审批全自动执行高危操作。
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) }
|
||||
// confirmDialog 返回 Promise<boolean>(true=确认,false=取消),无 title/content/confirmText/cancelText
|
||||
// 字段。此处用 msg 拼标题+内容,dangerLabel 作确认按钮文案,自渲染独立弹层(上方模板)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// 三档合法值 + 脏值白名单兜底:KV 被外部改库为非法值(空串/非三档)时回退 low,
|
||||
// 防 <select v-model> 无匹配 option 致控件空白(后端 audit 脏值 fail-safe 降级审批,前端需对齐可视)。
|
||||
const VALID_MODES = ['low', 'medium', 'all'] as const
|
||||
type AutoMode = (typeof VALID_MODES)[number]
|
||||
function clampMode(raw: string): AutoMode {
|
||||
return (VALID_MODES as readonly string[]).includes(raw) ? (raw as AutoMode) : 'low'
|
||||
}
|
||||
|
||||
const settings = reactive({
|
||||
autoExecute: false,
|
||||
// 三档 low/medium/all,默认 low(=旧 autoExecute=false 现状),KV 持久化 df-ai-auto-execute-mode。
|
||||
// clampMode 兜底脏值。
|
||||
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
|
||||
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
|
||||
function markSaved(key: 'autoExecute' | 'logLevel') {
|
||||
const map = { autoExecute: rowAutoExecute.value, logLevel: rowLogLevel.value }
|
||||
map[key]?.markSaved()
|
||||
|
||||
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
|
||||
// 取消后被强制降级 low。初始化 = 当前有效值;每次确认成功(含非 all 切换)更新。
|
||||
let lastConfirmedMode: AutoMode = settings.autoExecuteMode
|
||||
|
||||
// all(完全接管)二次确认:防误选致无审批全自动执行高危操作(删文件/执行任意命令)
|
||||
// 选 all → 弹确认 → 确认落 KV + markSaved + 更新 lastConfirmedMode;取消恢复 lastConfirmedMode(不落 KV)。
|
||||
// 非 all 切换:直接落 KV + 更新 lastConfirmedMode(watch 兜底同值无副作用)。
|
||||
async function onAutoModeChange() {
|
||||
if (settings.autoExecuteMode === 'all') {
|
||||
const msg = t('settings.autoModeAllConfirmTitle') + '\n\n' + t('settings.autoModeAllConfirmContent')
|
||||
const ok = await confirmDialog(msg, t('common.confirm'))
|
||||
if (!ok) {
|
||||
settings.autoExecuteMode = lastConfirmedMode // 取消恢复原档位(非写死 low)
|
||||
return
|
||||
}
|
||||
}
|
||||
appSettings.set('df-ai-auto-execute-mode', settings.autoExecuteMode)
|
||||
lastConfirmedMode = settings.autoExecuteMode
|
||||
rowAutoExecute.value?.markSaved()
|
||||
}
|
||||
|
||||
// watch 兜底:非 all 切换直接落 KV + 更新 lastConfirmedMode(all 走 onAutoModeChange 确认后落,
|
||||
// 避免 watch 抢先持久化未确认的 all 值)。onAutoModeChange 已落 KV 时此 watch 重复 set 同值无副作用。
|
||||
watch(
|
||||
() => settings.autoExecuteMode,
|
||||
(v) => {
|
||||
if (v !== 'all') {
|
||||
appSettings.set('df-ai-auto-execute-mode', v)
|
||||
lastConfirmedMode = v
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// logLevel 行即时反馈(无参:logLevel 仅 session 内 reactive 无持久化,markSaved 只显「已保存」反馈)
|
||||
function markSaved() {
|
||||
rowLogLevel.value?.markSaved()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
|
||||
.setting-select/.toggle/.toggle-slider + SettingRow(封装 .setting-row/.setting-info/
|
||||
.setting-label/.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。 */
|
||||
.setting-select + SettingRow(封装 .setting-row/.setting-info/.setting-label/
|
||||
.setting-desc/.setting-control)。本 Section 无特有 scoped 样式。
|
||||
|
||||
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
|
||||
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.confirm-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<!-- ═══ 通用设置(聚合壳:外观 + 性能 + 高级)═══
|
||||
阶段2 UX 重构:原 GeneralPanel 内联内容拆为 3 Section。
|
||||
本阶段(阶段2)Settings.vue 仍引 GeneralPanel,故此处作聚合壳组合 3 Section 保持不破;
|
||||
下一阶段(阶段3 master-detail)Settings.vue 改为直接引各 Section 后,本聚合壳即可弃用。 -->
|
||||
<AppearanceSection />
|
||||
<PerformanceSection />
|
||||
<AdvancedSection />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
|
||||
import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 阶段2 UX 重构:聚合壳仅组合 3 Section,无自身样式。
|
||||
各 Section 复用 settings.css 全局 .panel 等 class,Section 之间间距由父 .settings-grid 控制。 */
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@
|
||||
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
|
||||
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
|
||||
// security 安全(allowed_dirs)
|
||||
// advanced 高级(autoExecute/logLevel + 连接)
|
||||
// advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||
// ============================================================
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface SettingIndexEntry {
|
||||
* performance 性能(global-concurrency/per-conv-concurrency/
|
||||
* max-iterations/max-retries)
|
||||
* security 安全(allowed_dirs)
|
||||
* advanced 高级(autoExecute/logLevel + 连接)
|
||||
* advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||
*/
|
||||
export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
||||
// ===== 外观 =====
|
||||
@@ -82,7 +82,8 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
||||
{ category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' },
|
||||
|
||||
// ===== 高级 =====
|
||||
{ category: 'advanced', key: 'autoExecute', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
|
||||
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
|
||||
{ category: 'advanced', key: 'autoExecuteMode', labelKey: 'settings.labelAutoExecute', descKey: 'settings.descAutoExecute' },
|
||||
{ category: 'advanced', key: 'logLevel', labelKey: 'settings.labelLogLevel', descKey: 'settings.descLogLevel' },
|
||||
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { ref } from 'vue'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -29,7 +29,7 @@ import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearA
|
||||
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { loadConversations } from './useAiConversations'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
|
||||
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
@@ -45,7 +45,8 @@ const appSettings = useAppSettingsStore()
|
||||
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
|
||||
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
|
||||
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
|
||||
// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。
|
||||
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
|
||||
|
||||
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
|
||||
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
|
||||
@@ -121,6 +122,52 @@ export interface PendingDirAuth {
|
||||
}
|
||||
export const pendingDirAuths = ref<PendingDirAuth[]>([])
|
||||
|
||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
//
|
||||
// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。
|
||||
// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。
|
||||
// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号,
|
||||
// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId,
|
||||
// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。
|
||||
// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。
|
||||
export interface PendingHelp {
|
||||
reason: string
|
||||
context: string
|
||||
options: string[]
|
||||
conversationId: string | null
|
||||
}
|
||||
export const pendingHelp = ref<PendingHelp | null>(null)
|
||||
|
||||
// L2 统一状态机(批2 1c):per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
|
||||
//
|
||||
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式,
|
||||
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之
|
||||
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留——
|
||||
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。
|
||||
//
|
||||
// 写收敛:仅本模块 handleEvent 的 AiConvStateChanged case 写(setConvState 辅助),其他地方只读。
|
||||
// 兜底同步:idle/error/compressed 在 case 内同步做旧 generatingConvs 收敛(AiCompleted/AiError 同义),
|
||||
// 保持双轨真相一致——后端 emit 可能与 AiCompleted/AiError 不严格按序到达,任一来源触发收敛均可。
|
||||
export const convStates = reactive(new Map<string, ConvState>())
|
||||
|
||||
/** 取某 conv 的 conv_state;未追踪过返回 null(消费方据此回退旧 bool 判断) */
|
||||
export function getConvState(convId: string | null | undefined): ConvState | null {
|
||||
if (!convId) return null
|
||||
return convStates.get(convId) ?? null
|
||||
}
|
||||
|
||||
/** 写入 conv_state(本模块内 AiConvStateChanged case 唯一写入口) */
|
||||
function setConvState(convId: string | null | undefined, s: ConvState): void {
|
||||
if (!convId) return
|
||||
// idle 为终态收敛:写后可保留也可删,这里删以让 getConvState 回退旧 bool(语义对齐:
|
||||
// 后端 generating=false 即 Idle,前端旧 bool 也是 false,删 Map 项后消费方回退 bool 即得正确态)。
|
||||
if (s === 'idle') {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
convStates.set(convId, s)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
export function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
@@ -148,6 +195,13 @@ export function flushCurrentText() {
|
||||
break
|
||||
}
|
||||
}
|
||||
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。
|
||||
// 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。
|
||||
// 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText
|
||||
// 残留 → MessageList 渲染侧 isLastAi(msg)&¤tText 把残留文本渲到新气泡 → 重叠堆叠。
|
||||
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
|
||||
// currentText='' 对已清空值幂等,无副作用。
|
||||
state.currentText = ''
|
||||
}
|
||||
|
||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||
@@ -266,16 +320,23 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiMaxRoundsReached':
|
||||
case 'AiMaxRoundsReached': {
|
||||
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
|
||||
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
|
||||
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
|
||||
// BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走,
|
||||
// 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报
|
||||
// "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。
|
||||
// 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。
|
||||
// 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
|
||||
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
|
||||
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
|
||||
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
|
||||
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiDirAuthRequired': {
|
||||
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
|
||||
@@ -324,6 +385,11 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
|
||||
startApprovalTimer(event.id, event.tool, 'path')
|
||||
}
|
||||
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):path 类审批卡归一进 ToolCard
|
||||
// 内联后,可能落入同名工具≥2 的折叠分组(ToolCardList group-hidden display:none)对用户
|
||||
// 不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 authorizeDir('deny')→ 误显
|
||||
// "用户拒绝" → 用户全程未见审批入口即卡死。通知 ToolCardList 自动展开折叠组 + scroll。
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
return true
|
||||
}
|
||||
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
|
||||
@@ -416,6 +482,8 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
|
||||
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
|
||||
startApprovalTimer(event.id, event.name, 'risk')
|
||||
// BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。
|
||||
void emit('ai-pending-arrived', { toolCallId: event.id })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -453,6 +521,61 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。
|
||||
*
|
||||
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool
|
||||
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
|
||||
* 终止态(idle/error):
|
||||
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
|
||||
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
|
||||
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
|
||||
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
|
||||
* 返回值:true=已处理(命中 case);false=未命中。 */
|
||||
function handleConvStateEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiConvStateChanged') return false
|
||||
const convId = event.conversation_id || state.activeConversationId
|
||||
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
|
||||
const cs = event.conv_state
|
||||
setConvState(convId, cs)
|
||||
// 终止态同步收敛旧 generatingConvs(双轨过渡,与 AiCompleted/AiError 同义)。
|
||||
// compressed 非终止(派生态,压缩完回原态),不收敛。
|
||||
if (cs === 'idle' || cs === 'error') {
|
||||
state.generatingConvs.delete(convId)
|
||||
} else if (cs === 'generating' || cs === 'stopping' || cs === 'compressed') {
|
||||
// 非终止态同步加 generatingConvs(确保旧 bool 真相与 conv_state 一致,
|
||||
// 防 AiConvStateChanged{generating} 先于首个 delta 到达时旧 bool 漏加致停止按钮/卡片错态)。
|
||||
state.generatingConvs.add(convId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 跨端用户消息域:F-260622-02 miniapp→桌面 user 气泡同步。
|
||||
*
|
||||
* 仅处理 AiUserMessage 一个事件。语义:远程(miniapp)发来的用户消息经后端 publish_event
|
||||
* 广播到 ai_event_bus,桌面收到后补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件,
|
||||
* 故桌面 store 不会有这条 user 气泡,需要此事件填补,否则桌面只看到孤立 assistant 响应气泡)。
|
||||
*
|
||||
* 去重(防双气泡):若末条消息已是同 content 的 user(理论上桌面本地已乐观 push 过,
|
||||
* 或 miniapp 自身乐观 push 后又被此事件回灌),跳过不重复 push。MVP 按末条 + content 比对
|
||||
* 足够覆盖单会话场景;极端情况下两条不同 user 恰巧同 content 紧邻不会丢(末条不同才 push)。
|
||||
*
|
||||
* 返回值:true=命中 AiUserMessage;false=其他事件。 */
|
||||
function handleUserMessageEvent(event: AiChatEvent): boolean {
|
||||
if (event.type !== 'AiUserMessage') return false
|
||||
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
|
||||
const last = state.messages[state.messages.length - 1]
|
||||
if (last && last.role === 'user' && last.content === event.message) {
|
||||
return true
|
||||
}
|
||||
state.messages.push({
|
||||
id: `user-${nextMsgId()}`,
|
||||
role: 'user',
|
||||
content: event.message,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
|
||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
@@ -463,11 +586,19 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
state.currentText = ''
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
|
||||
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
|
||||
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。
|
||||
convStates.delete(event.conversation_id || '')
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。
|
||||
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
|
||||
pendingHelp.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
|
||||
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
|
||||
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
|
||||
@@ -523,6 +654,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
|
||||
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
|
||||
if (event.conversation_id) convStates.set(event.conversation_id, 'error')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||
@@ -533,6 +668,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。
|
||||
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
|
||||
pendingHelp.value = null
|
||||
}
|
||||
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
|
||||
const errConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
|
||||
@@ -558,6 +697,47 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiHelpRequired': {
|
||||
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
|
||||
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
|
||||
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
|
||||
// 显 reason + options 按钮供用户选。
|
||||
clearStreamWatchdog(event.conversation_id || undefined)
|
||||
clearAllToolSlowTimers()
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0
|
||||
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
|
||||
state.pendingApprovals = []
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。
|
||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||
pendingMaxRounds.value = null
|
||||
}
|
||||
// per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。
|
||||
const helpConv2 = event.conversation_id || ''
|
||||
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2)
|
||||
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
|
||||
const helpConv = event.conversation_id || ''
|
||||
if (helpConv) {
|
||||
localStorage.removeItem(`df-ai-gen-${helpConv}`)
|
||||
localStorage.removeItem(`df-ai-text-${helpConv}`)
|
||||
}
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
|
||||
pendingHelp.value = {
|
||||
reason: event.reason,
|
||||
context: event.context,
|
||||
options: event.options,
|
||||
conversationId: event.conversation_id || state.activeConversationId || null,
|
||||
}
|
||||
void loadConversations()
|
||||
notifyConversationChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -574,19 +754,34 @@ export function handleEvent(event: AiChatEvent) {
|
||||
state.activeConversationId = convId
|
||||
void appSettings.set('df-ai-active-conv', convId)
|
||||
}
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
|
||||
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
|
||||
if (handleConvStateEvent(event)) return
|
||||
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
|
||||
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
|
||||
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
|
||||
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
||||
if (handleUserMessageEvent(event)) return
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
|
||||
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
|
||||
state.generatingConvs.delete(convId || '')
|
||||
void loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
|
||||
state.generatingConvs.add(convId)
|
||||
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
|
||||
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
|
||||
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
|
||||
convStates.delete(convId)
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
|
||||
|
||||
@@ -47,6 +47,19 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
// (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy,
|
||||
// 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补
|
||||
// "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
const stillGenerating = convId
|
||||
? state.generatingConvs.has(convId)
|
||||
: state.streaming
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
@@ -108,6 +121,9 @@ export function resetStreamWatchdog(convId?: string) {
|
||||
if (old) clearTimeout(old)
|
||||
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
|
||||
_watchdogTimers.set(convId, timer)
|
||||
// 根治(TD-260621-01 遗留):切到 per-conv 时清 legacy(doSend 启动的 legacy timer 与活跃事件
|
||||
// per-conv 共存期,legacy 是残留)。防 AiCompleted clear(convId) 清不到 legacy 致 130s 误报。
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
} else {
|
||||
// 禁动域 fallback:行为对齐旧版单 timer。
|
||||
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
|
||||
@@ -126,6 +142,10 @@ export function clearStreamWatchdog(convId?: string) {
|
||||
if (convId) {
|
||||
const timer = _watchdogTimers.get(convId)
|
||||
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
|
||||
// 根治(TD-260621-01 遗留):doSend reset 走 legacy,AiCompleted clear 走 per-conv Map,clear 清不到
|
||||
// legacy 致 130s 残留误报。批2 单 loop 阶段 legacy 单例只属当前会话,clear(convId) 兜底清 legacy 安全;
|
||||
// 批3+ 多会话落地时 useAiSend reset 迁 per-conv + 删 legacy fallback,此兜底随之移除。
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
} else {
|
||||
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ export default {
|
||||
|
||||
// ── Send ──
|
||||
stopGenerating: 'Stop generating',
|
||||
// L2 状态机停止按钮三态(ConvState 派生)
|
||||
stopping: 'Stopping',
|
||||
stopFailedRetry: 'Stop failed, click to retry',
|
||||
|
||||
// ── Input hint ──
|
||||
inputHint: 'Enter to send · Shift+Enter for newline',
|
||||
@@ -145,6 +148,9 @@ export default {
|
||||
continueLoopFailed: 'Continue failed: {msg}',
|
||||
stopLoopFailed: 'Stop failed: {msg}',
|
||||
|
||||
// ── L1 help protocol (aichat experience & agent capability redesign §2.3, 2026-06-21): circuit-breaker help card ──
|
||||
helpRequiredAck: 'Choice noted. Please rephrase your request to try another approach / continue',
|
||||
|
||||
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
|
||||
dirAuthRequired: 'Path authorization required',
|
||||
dirAuthHint: '{tool} wants to access: {path}',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user