commit e491b1a35b7e8bf7d7946f3a5830132682e94181 Author: JustMe Date: Tue Sep 29 20:47:00 2026 +0800 feat: extract MyAgent core and web console diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..875d85d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +**/bin/ +**/obj/ +**/.idea/ +**/node_modules/ +**/dist/ +**/data/ +**/logs/ +artifacts/ +.env +.env.* +README.md +agent.md +docs/ +MyAgent.Web/README.md diff --git a/MyAgent.Web/index.html b/MyAgent.Web/index.html new file mode 100644 index 0000000..756b8b4 --- /dev/null +++ b/MyAgent.Web/index.html @@ -0,0 +1,15 @@ + + + + + + + + MyAgent Console + + + +
+ + + diff --git a/MyAgent.Web/package-lock.json b/MyAgent.Web/package-lock.json new file mode 100644 index 0000000..8a17b76 --- /dev/null +++ b/MyAgent.Web/package-lock.json @@ -0,0 +1,1621 @@ +{ + "name": "myagent-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "myagent-web", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-vue": "latest", + "naive-ui": "^2.44.1", + "vite": "latest", + "vue": "latest" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "tailwindcss": "^4.3.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + } + } +} diff --git a/MyAgent.Web/package.json b/MyAgent.Web/package.json new file mode 100644 index 0000000..b077376 --- /dev/null +++ b/MyAgent.Web/package.json @@ -0,0 +1,21 @@ +{ + "name": "myagent-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 5174" + }, + "dependencies": { + "@vitejs/plugin-vue": "latest", + "naive-ui": "^2.44.1", + "vite": "latest", + "vue": "latest" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.1", + "tailwindcss": "^4.3.1" + } +} diff --git a/MyAgent.Web/src/App.vue b/MyAgent.Web/src/App.vue new file mode 100644 index 0000000..b266c12 --- /dev/null +++ b/MyAgent.Web/src/App.vue @@ -0,0 +1,1414 @@ + + + + + diff --git a/MyAgent.Web/src/api/client.js b/MyAgent.Web/src/api/client.js new file mode 100644 index 0000000..dfa9ab7 --- /dev/null +++ b/MyAgent.Web/src/api/client.js @@ -0,0 +1,45 @@ +export const API_BASE = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''); + +async function request(path, options = {}) { + const res = await fetch(API_BASE + path, options); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + return res; +} + +export const apiClient = { + async json(path, options) { + const res = await request(path, options); + return await res.json(); + }, + async text(path, options) { + const res = await request(path, options); + return await res.text(); + }, + async raw(path, options) { + return await request(path, options); + } +}; + +export function jsonOptions(body) { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} + +export function putJsonOptions(body) { + return { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }; +} + +export function textPutOptions(body) { + return { + method: 'PUT', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + body + }; +} diff --git a/MyAgent.Web/src/api/index.js b/MyAgent.Web/src/api/index.js new file mode 100644 index 0000000..b507b60 --- /dev/null +++ b/MyAgent.Web/src/api/index.js @@ -0,0 +1,64 @@ +import { apiClient, jsonOptions, putJsonOptions, textPutOptions } from './client.js'; + +const enc = encodeURIComponent; + +export const healthApi = { + get: () => apiClient.json('/api/health') +}; + +export const configApi = { + get: () => apiClient.json('/api/config'), + metadata: () => apiClient.json('/api/config/ui-metadata'), + save: (config) => apiClient.json('/api/config', putJsonOptions(config)) +}; + +export const modelApi = { + list: ({ baseUrl, apiKey }) => apiClient.json('/api/openai/models', jsonOptions({ baseUrl, apiKey })) +}; + +export const groupApi = { + profile: (groupId) => apiClient.json(`/api/group-chat/profile?groupId=${enc(groupId)}`), + recentEvents: (groupId, limit = 50) => apiClient.json(`/api/group-chat/recent?groupId=${enc(groupId)}&limit=${enc(limit)}`), + recentMessages: (groupId, limit = 80) => apiClient.json(`/api/groups/${enc(groupId)}/recent?limit=${enc(limit)}`), + conversations: (groupId) => apiClient.json(`/api/conversations?groupId=${enc(groupId)}`), + eventsUrl: (groupId) => `/api/group-chat/events?groupId=${enc(groupId)}` +}; + +export const contextApi = { + get: (groupId) => apiClient.text(`/api/groups/${enc(groupId)}/context`), + save: (groupId, text) => apiClient.json(`/api/groups/${enc(groupId)}/context`, textPutOptions(text)), + reset: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/reset`, { method: 'POST' }), + clearAll: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/clear-all`, { method: 'POST' }), + refresh: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/context/refresh`, { method: 'POST' }), + parts: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/context-parts?userId=${enc(userId || '')}`), + heavyMemory: (groupId, limit = 50) => apiClient.json(`/api/groups/${enc(groupId)}/heavy-memory?limit=${enc(limit)}`), + searchHeavyMemory: (groupId, query, limit = 8) => apiClient.json(`/api/groups/${enc(groupId)}/heavy-memory/search?q=${enc(query || '')}&limit=${enc(limit)}`), + lightRecallCache: (groupId) => apiClient.text(`/api/groups/${enc(groupId)}/light-recall-cache`) +}; + +export const memberMemoryApi = { + get: (groupId, userId) => apiClient.text(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`), + save: (groupId, userId, text) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`, textPutOptions(text)), + reset: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory/reset`, { method: 'POST' }), + remove: (groupId, userId) => apiClient.json(`/api/groups/${enc(groupId)}/members/${enc(userId)}/memory`, { method: 'DELETE' }), + list: (groupId) => apiClient.json(`/api/groups/${enc(groupId)}/members`) +}; + +export const taskApi = { + list: () => apiClient.json('/api/tasks'), + get: (taskId) => apiClient.json(`/api/tasks/${enc(taskId)}`) +}; + +export const mediaApi = { + sources: () => apiClient.json('/api/media/sources'), + search: (request) => apiClient.json('/api/media/search', jsonOptions(request)), + download: (request) => apiClient.json('/api/media/download', jsonOptions(request)) +}; + +export const toolApi = { + catalog: () => apiClient.json('/api/tools/catalog') +}; + +export const workflowApi = { + metadata: () => apiClient.json('/api/workflow/metadata') +}; diff --git a/MyAgent.Web/src/components/pages/ConfigPage.vue b/MyAgent.Web/src/components/pages/ConfigPage.vue new file mode 100644 index 0000000..b192b5b --- /dev/null +++ b/MyAgent.Web/src/components/pages/ConfigPage.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/MyAgent.Web/src/components/pages/ContextPage.vue b/MyAgent.Web/src/components/pages/ContextPage.vue new file mode 100644 index 0000000..2714748 --- /dev/null +++ b/MyAgent.Web/src/components/pages/ContextPage.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/MyAgent.Web/src/components/pages/GroupPage.vue b/MyAgent.Web/src/components/pages/GroupPage.vue new file mode 100644 index 0000000..6cb876c --- /dev/null +++ b/MyAgent.Web/src/components/pages/GroupPage.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/MyAgent.Web/src/components/pages/MediaPage.vue b/MyAgent.Web/src/components/pages/MediaPage.vue new file mode 100644 index 0000000..cca6666 --- /dev/null +++ b/MyAgent.Web/src/components/pages/MediaPage.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/MyAgent.Web/src/components/pages/TasksPage.vue b/MyAgent.Web/src/components/pages/TasksPage.vue new file mode 100644 index 0000000..8321d68 --- /dev/null +++ b/MyAgent.Web/src/components/pages/TasksPage.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/MyAgent.Web/src/main.js b/MyAgent.Web/src/main.js new file mode 100644 index 0000000..6bf8251 --- /dev/null +++ b/MyAgent.Web/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import naive from 'naive-ui'; +import App from './App.vue'; + +createApp(App).use(naive).mount('#app'); diff --git a/MyAgent.Web/vite.config.js b/MyAgent.Web/vite.config.js new file mode 100644 index 0000000..d9d2a09 --- /dev/null +++ b/MyAgent.Web/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + build: { + rolldownOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/naive-ui')) return 'naive-ui' + if (id.includes('node_modules/vue')) return 'vue-runtime' + } + } + } + }, + server: { + proxy: { + '/api': { + target: process.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000', + changeOrigin: true + } + } + } +}) diff --git a/MyAgent/Agents/AnalystAgent.cs b/MyAgent/Agents/AnalystAgent.cs new file mode 100644 index 0000000..562e441 --- /dev/null +++ b/MyAgent/Agents/AnalystAgent.cs @@ -0,0 +1,62 @@ +using MyAgent.Core; + +namespace MyAgent.Agents; + +public sealed class AnalystAgent(IChatModelClient client) +{ + public async Task AnalyzeAsync(MessageBatch batch, PlannerDecisionEnvelope plan, IReadOnlyList observations, CancellationToken ct = default) + { + if (!plan.NeedsAnalysis) + { + return new AnalysisResult + { + Conclusion = observations.Count == 0 ? "无需额外分析。" : string.Join("\n", observations.Select(o => o.EvidenceSummary)), + Citations = observations.SelectMany(o => o.Items.Select(i => i.Url)).Where(x => !string.IsNullOrWhiteSpace(x)).Take(5).ToList() + }; + } + + var system = """ +你是证据分析 Agent。只基于给定工具观察和上下文输出 JSON,不直接写 QQ 回复。 +输出字段:conclusion, risks, uncertainties, citations。 +不要编造来源;证据不足就写入 uncertainties。 +"""; + var user = $$""" +当前问题: +{{batch.MergedText}} + +任务类型:{{plan.TaskType}} + +工具观察: +{{string.Join("\n\n", observations.Select(o => $"工具={o.ToolName}\n状态={o.Status}\n摘要={o.EvidenceSummary}\n错误={o.Error}"))}} + +请输出 JSON:{"conclusion":"...","risks":["..."],"uncertainties":["..."],"citations":["..."]} +"""; + + try + { + var result = await client.CompleteRoutingAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user } + ], 0.0, 500, ct); + var json = ExtractJson(result.Content); + var parsed = System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + return parsed ?? new AnalysisResult { Conclusion = result.Content }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new AnalysisResult + { + Conclusion = string.Join("\n", observations.Select(o => o.EvidenceSummary)), + Uncertainties = ["分析模型输出不可解析,已退回工具摘要。"], + Citations = observations.SelectMany(o => o.Items.Select(i => i.Url)).Where(x => !string.IsNullOrWhiteSpace(x)).Take(5).ToList() + }; + } + } + + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + return start >= 0 && end > start ? text[start..(end + 1)] : text; + } +} diff --git a/MyAgent/Agents/HeavyMemoryRecallAgent.cs b/MyAgent/Agents/HeavyMemoryRecallAgent.cs new file mode 100644 index 0000000..2415e2f --- /dev/null +++ b/MyAgent/Agents/HeavyMemoryRecallAgent.cs @@ -0,0 +1,130 @@ +using System.Text; +using System.Text.Json; +using MyAgent.Storage; + +namespace MyAgent.Agents; + +public sealed class HeavyMemoryRecallAgent(HeavyMemoryStore store, IChatModelClient llm) +{ + public async Task RecallIfNeededAsync(MessageBatch batch, bool requested, CancellationToken ct = default) + { + if (!requested) return new HeavyMemoryRecallResult { ShouldRecall = false, Note = "planner_weight_below_threshold" }; + + var candidates = await store.SearchAsync(batch.GroupId, batch.MergedText, 5, ct); + if (candidates.Count == 0) + { + return new HeavyMemoryRecallResult + { + ShouldRecall = true, + NeedClarification = true, + ClarifyingQuestion = "我知道有重记忆,但没按这句话直接找到对应片段。你能补一个关键词、人大概是谁,或者大概时间吗?", + Note = "no_candidates" + }; + } + + var decision = await AskModelAsync(batch.MergedText, candidates, ct); + if (!decision.ShouldUse) + { + return new HeavyMemoryRecallResult + { + ShouldRecall = true, + NeedClarification = true, + ClarifyingQuestion = string.IsNullOrWhiteSpace(decision.ClarifyingQuestion) + ? "我可以查重记忆,但这个指代有点宽。你想回忆的是哪个话题/时间段?" + : decision.ClarifyingQuestion, + Note = "model_requested_clarification" + }; + } + + var selected = candidates + .Where(x => decision.SelectedIds.Count == 0 || decision.SelectedIds.Contains(x.Id, StringComparer.OrdinalIgnoreCase)) + .Take(3) + .ToList(); + if (selected.Count == 0) selected = candidates.Take(3).ToList(); + + return new HeavyMemoryRecallResult + { + ShouldRecall = true, + NeedClarification = false, + Entries = selected, + LightMemoryText = HeavyMemoryStore.FormatRecallText(batch.MergedText, selected), + Note = "recalled" + }; + } + + private async Task AskModelAsync(string query, IReadOnlyList candidates, CancellationToken ct) + { + var brief = new StringBuilder(); + foreach (var e in candidates) + { + brief.AppendLine($"id={e.Id}"); + brief.AppendLine($"topic={e.Topic}"); + brief.AppendLine($"time={e.StartAt:yyyy-MM-dd HH:mm}~{e.EndAt:yyyy-MM-dd HH:mm}"); + brief.AppendLine($"keywords={string.Join("/", e.Keywords)}"); + brief.AppendLine($"summary={e.Summary}"); + brief.AppendLine(); + } + + var system = """ +你是重记忆召回判定器。重记忆平时不访问,只有用户明显在要求回忆过往对话/决定/曾经说过的内容时才用。 +任务:从候选重记忆中判断是否足以回答。若指代模糊,输出一个很小的澄清问题,不要强行召回。 +只输出 JSON:{"shouldUse":true/false,"selectedIds":["..."],"clarifyingQuestion":"..."} +"""; + var prompt = $$""" +用户当前问题:{{query}} + +候选重记忆: +{{brief}} +"""; + var result = await llm.CompleteMemoryAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = prompt } + ], 0.1, 400, ct); + + if (result.IsFallback || string.IsNullOrWhiteSpace(result.Content)) + return new RecallDecision { ShouldUse = candidates.Count > 0, SelectedIds = candidates.Take(3).Select(x => x.Id).ToList() }; + + try + { + var json = ExtractJson(result.Content); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new RecallDecision + { + ShouldUse = root.TryGetProperty("shouldUse", out var s) && s.GetBoolean(), + SelectedIds = root.TryGetProperty("selectedIds", out var ids) && ids.ValueKind == JsonValueKind.Array + ? ids.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrWhiteSpace(x)).ToList() + : [], + ClarifyingQuestion = root.TryGetProperty("clarifyingQuestion", out var q) ? q.GetString() ?? "" : "" + }; + } + catch + { + return new RecallDecision { ShouldUse = candidates.Count > 0, SelectedIds = candidates.Take(3).Select(x => x.Id).ToList() }; + } + } + + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + return start >= 0 && end > start ? text[start..(end + 1)] : text; + } + + private sealed class RecallDecision + { + public bool ShouldUse { get; set; } + public List SelectedIds { get; set; } = []; + public string ClarifyingQuestion { get; set; } = ""; + } +} + +public sealed class HeavyMemoryRecallResult +{ + public bool ShouldRecall { get; set; } + public bool NeedClarification { get; set; } + public string ClarifyingQuestion { get; set; } = ""; + public IReadOnlyList Entries { get; set; } = []; + public string LightMemoryText { get; set; } = ""; + public string Note { get; set; } = ""; +} diff --git a/MyAgent/Agents/IChatModelClient.cs b/MyAgent/Agents/IChatModelClient.cs new file mode 100644 index 0000000..fba41c8 --- /dev/null +++ b/MyAgent/Agents/IChatModelClient.cs @@ -0,0 +1,10 @@ +namespace MyAgent.Agents; + +public interface IChatModelClient +{ + Task CompleteRoutingAsync(IEnumerable messages, double temperature, int maxTokens = 500, CancellationToken ct = default); + Task CompleteReplyAsync(IEnumerable messages, double temperature, int maxTokens = 1200, CancellationToken ct = default); + Task CompleteMemoryAsync(IEnumerable messages, double temperature, int maxTokens = 2000, CancellationToken ct = default); + Task CreateEmbeddingAsync(string input, CancellationToken ct = default); + IAsyncEnumerable StreamCompleteAsync(IEnumerable messages, double temperature, int maxTokens = 1200, CancellationToken ct = default); +} diff --git a/MyAgent/Agents/MemoryWorker.cs b/MyAgent/Agents/MemoryWorker.cs new file mode 100644 index 0000000..30dfadb --- /dev/null +++ b/MyAgent/Agents/MemoryWorker.cs @@ -0,0 +1,378 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; + +namespace MyAgent.Agents; + +public sealed class MemoryWorker( + GroupContextStore contextStore, + HeavyMemoryStore heavyMemoryStore, + RecentLogStore recentLog, + IChatModelClient llm, + LocalConfigStore configStore, + IOptions options, + ILogger logger) : BackgroundService +{ + private readonly GroupContextStore _contextStore = contextStore; + private readonly HeavyMemoryStore _heavyMemoryStore = heavyMemoryStore; + private readonly RecentLogStore _recentLog = recentLog; + private readonly IChatModelClient _llm = llm; + private readonly LocalConfigStore _configStore = configStore; + private readonly ShirobotOptions _options = options.Value; + private readonly ILogger _logger = logger; + private readonly Channel<(MessageBatch Batch, string? Answer)> _queue = Channel.CreateBounded<(MessageBatch, string?)>(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + private readonly Dictionary _pendingByGroup = new(); + private readonly Dictionary _pendingByMember = new(); + private readonly Dictionary _lastCompress = new(); + private readonly Dictionary _lastMemberCompress = new(); + private const int MemberCompressEveryMessages = 10; + + public void Enqueue(MessageBatch batch, string? answer) + { + if (_options.Memory.Enabled) + { + // Memory compression is strictly background best-effort work. + // Never block the QQ reply path if the queue is full or the worker is busy. + _queue.Writer.TryWrite((batch, answer)); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var item in _queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + var groupId = item.Batch.GroupId; + _pendingByGroup[groupId] = _pendingByGroup.GetValueOrDefault(groupId) + item.Batch.Messages.Count; + + foreach (var userGroup in item.Batch.Messages.Where(m => !string.IsNullOrWhiteSpace(m.UserId)).GroupBy(m => m.UserId)) + { + var memberKey = MemberKey(groupId, userGroup.Key); + _pendingByMember[memberKey] = _pendingByMember.GetValueOrDefault(memberKey) + userGroup.Count(); + var memberLast = _lastMemberCompress.GetValueOrDefault(memberKey, DateTimeOffset.MinValue); + var memberMd = await _contextStore.ReadOrCreateMemberAsync(groupId, userGroup.Key, stoppingToken); + var shouldCompressMember = _pendingByMember[memberKey] >= MemberCompressEveryMessages + || memberMd.Length > Math.Max(1800, _options.Memory.ContextSoftLimitChars / 3) + || (memberLast != DateTimeOffset.MinValue && (DateTimeOffset.Now - memberLast).TotalMinutes >= _options.Memory.CompressEveryMinutes * 2); + if (shouldCompressMember) + { + using var memberTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + memberTimeout.CancelAfter(TimeSpan.FromSeconds(75)); + var memberResult = await CompressMemberAsync(groupId, userGroup.Key, memberTimeout.Token); + _pendingByMember[memberKey] = 0; + _lastMemberCompress[memberKey] = DateTimeOffset.Now; + if (!memberResult.Ok) _logger.LogWarning("Member memory compression skipped/failed: {Message}", memberResult.Message); + } + } + + var last = _lastCompress.GetValueOrDefault(groupId, DateTimeOffset.MinValue); + var shouldCompressByCount = _pendingByGroup[groupId] >= _options.Memory.CompressEveryMessages; + var shouldCompressByTime = last != DateTimeOffset.MinValue && (DateTimeOffset.Now - last).TotalMinutes >= _options.Memory.CompressEveryMinutes; + var shouldCompress = shouldCompressByCount || shouldCompressByTime; + if (!shouldCompress) + { + var md = await _contextStore.ReadOrCreateAsync(groupId, stoppingToken); + shouldCompress = md.Length > _options.Memory.ContextSoftLimitChars; + } + + if (shouldCompress) + { + using var compressTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + compressTimeout.CancelAfter(TimeSpan.FromSeconds(120)); + var result = await CompressAsync(groupId, compressTimeout.Token); + _pendingByGroup[groupId] = 0; + _lastCompress[groupId] = DateTimeOffset.Now; + if (!result.Ok) _logger.LogWarning("Memory compression skipped/failed: {Message}", result.Message); + var heavyResult = await ExtractHeavyMemoriesAsync(groupId, compressTimeout.Token); + if (!heavyResult.Ok) _logger.LogWarning("Heavy memory extraction skipped/failed: {Message}", heavyResult.Message); + } + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // Background memory compression is best-effort. Timeout/cancel must never stop the host. + _logger.LogWarning("Memory worker compression timed out; skipped this round."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Memory worker failed"); + } + } + } + + public async Task RefreshAsync(string groupId, CancellationToken ct = default) + { + return await CompressAsync(groupId, ct); + } + + private static string MemberKey(string groupId, string userId) => groupId + ":" + userId; + + private async Task CompressAsync(string groupId, CancellationToken ct) + { + var md = await _contextStore.ReadOrCreateAsync(groupId, ct); + var recent = await _recentLog.ReadRecentAsync(groupId, 80, ct); + var recentText = string.Join("\n", recent.Select(m => $"[{m.CreatedAt:MM-dd HH:mm}] {m.UserId}: {m.Text}")); + + var system = """ +你是 ShiroBot 的群上下文整理器。你只负责重写一个 Markdown 群上下文文件。 +要求: +- 保留机器人设定、群长期信息、当前活跃话题、群规则与偏好、重要决策、最近摘要。 +- context.md 是群级轻记忆,只保留当前话题大纲、长期稳定规则和群约定;不要堆原始对话、时间流水或细枝末节。 +- 群友 memory.md 也是轻记忆,只保存大纲、稳定偏好和搜索消歧;不要写成聊天记录。 +- 重记忆另存精确时间节点、原始对话和细节,不在一般回复中读取;只有用户要求回忆过去/精确细节时才按需召回到轻记忆暂存。 +- 不要在群 context.md 里堆大量单个群友偏好;单人信息会写入独立 members/{userId}.md。 +- 删除刷屏、表情、重复、过期玩笑、无意义口癖。 +- 连续纠正时以最后稳定版本为准,不保存中间口误。 +- 不保存撤回消息或明显隐私敏感信息。 +- 输出完整 Markdown,不要解释。 +"""; + var prompt = $$""" +现有 context.md: +{{md}} + +最近消息: +{{recentText}} + +请输出压缩后的完整 context.md。 +"""; + + var runtime = await _configStore.ReadAsync(ct); + var watch = System.Diagnostics.Stopwatch.StartNew(); + LlmResult result; + try + { + result = await _llm.CompleteMemoryAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = prompt } + ], 0.2, 1400, ct); + } + catch (OperationCanceledException) + { + watch.Stop(); + return new MemoryRefreshResult + { + Ok = false, + GroupId = groupId, + RecentMessages = recent.Count, + Changed = false, + Message = $"memoryModel={runtime.OpenAi.MemoryModel};耗时 {watch.ElapsedMilliseconds}ms;记忆整理被取消/超时,已跳过,不影响主回复" + }; + } + watch.Stop(); + + if (!result.IsFallback && result.Content.StartsWith("#")) + { + var changed = !string.Equals(md.Trim(), result.Content.Trim(), StringComparison.Ordinal); + await _contextStore.WriteAsync(groupId, result.Content, ct); + return new MemoryRefreshResult + { + Ok = true, + GroupId = groupId, + RecentMessages = recent.Count, + Changed = changed, + Message = (changed ? "context.md 已根据最近群聊整理更新" : "context.md 已检查,模型认为无需变化") + $";memoryModel={runtime.OpenAi.MemoryModel};耗时 {watch.ElapsedMilliseconds}ms" + }; + } + + return new MemoryRefreshResult + { + Ok = false, + GroupId = groupId, + RecentMessages = recent.Count, + Changed = false, + Message = $"memoryModel={runtime.OpenAi.MemoryModel};耗时 {watch.ElapsedMilliseconds}ms;" + (result.Error ?? "上下文整理失败:模型没有返回完整 Markdown") + }; + } + + private async Task ExtractHeavyMemoriesAsync(string groupId, CancellationToken ct) + { + var recent = await _recentLog.ReadRecentAsync(groupId, 120, ct); + if (recent.Count < 8) return new MemoryRefreshResult { Ok = true, GroupId = groupId, Changed = false, RecentMessages = recent.Count, Message = "重记忆抽取跳过:最近消息不足" }; + + var recentText = string.Join("\n", recent.Select(m => $"[{m.CreatedAt:yyyy-MM-dd HH:mm:ss zzz}] id={m.MessageId} user={m.UserId}: {m.Text}")); + var system = """ +你是 ShiroBot 的重记忆归档器。重记忆用于“用户明确要求回忆过去”时检索,平时不进入回复上下文。 +请从最近群聊中抽取值得长期备份的话题片段。只保留确实有后续回忆价值的内容:明确决定、需求变更、项目方案、排障结论、长期约定、重要事实。 +每条重记忆必须包含精确时间节点、话题、模型总结、参与者、关键词、原始对话摘录、来源消息 id。 +不要保存:闲聊水话、表情、隐私敏感信息、账号密钥、一次性口癖、无结论争吵。 +只输出 JSON:{"items":[{"topic":"...","summary":"...","startAt":"ISO时间","endAt":"ISO时间","participants":["..."],"keywords":["..."],"sourceMessageIds":["..."],"rawDialogue":"原始对话摘录","importance":1-5}]} +如果没有值得归档的内容,输出 {"items":[]}。 +"""; + var prompt = $$""" +群:{{groupId}} +最近消息: +{{recentText}} +"""; + var runtime = await _configStore.ReadAsync(ct); + var watch = System.Diagnostics.Stopwatch.StartNew(); + try + { + var result = await _llm.CompleteMemoryAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = prompt } + ], 0.1, 2400, ct); + watch.Stop(); + if (result.IsFallback || string.IsNullOrWhiteSpace(result.Content)) + return new MemoryRefreshResult { Ok = false, GroupId = groupId, RecentMessages = recent.Count, Message = result.Error ?? "重记忆抽取失败:模型无输出" }; + + var entries = ParseHeavyMemoryEntries(groupId, result.Content); + var added = await _heavyMemoryStore.AppendEntriesAsync(groupId, entries, ct); + return new MemoryRefreshResult + { + Ok = true, + GroupId = groupId, + RecentMessages = recent.Count, + Changed = added > 0, + Message = $"重记忆抽取完成,新增 {added} 条;memoryModel={runtime.OpenAi.MemoryModel};耗时 {watch.ElapsedMilliseconds}ms" + }; + } + catch (OperationCanceledException) + { + watch.Stop(); + return new MemoryRefreshResult { Ok = false, GroupId = groupId, RecentMessages = recent.Count, Message = $"重记忆抽取被取消/超时;耗时 {watch.ElapsedMilliseconds}ms" }; + } + catch (Exception ex) + { + watch.Stop(); + return new MemoryRefreshResult { Ok = false, GroupId = groupId, RecentMessages = recent.Count, Message = "重记忆抽取失败:" + ex.Message }; + } + } + + private static IReadOnlyList ParseHeavyMemoryEntries(string groupId, string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start < 0 || end <= start) return []; + var json = text[start..(end + 1)]; + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("items", out var items) || items.ValueKind != System.Text.Json.JsonValueKind.Array) return []; + var list = new List(); + foreach (var item in items.EnumerateArray()) + { + var entry = new HeavyMemoryEntry + { + GroupId = groupId, + Topic = GetString(item, "topic"), + Summary = GetString(item, "summary"), + StartAt = ParseTime(GetString(item, "startAt")), + EndAt = ParseTime(GetString(item, "endAt")), + Participants = GetStringArray(item, "participants"), + Keywords = GetStringArray(item, "keywords"), + SourceMessageIds = GetStringArray(item, "sourceMessageIds"), + RawDialogue = GetString(item, "rawDialogue"), + Importance = GetInt(item, "importance", 3), + CreatedAt = DateTimeOffset.Now + }; + if (!string.IsNullOrWhiteSpace(entry.Topic) && !string.IsNullOrWhiteSpace(entry.Summary) && !string.IsNullOrWhiteSpace(entry.RawDialogue)) + list.Add(entry); + } + return list; + } + + private static string GetString(System.Text.Json.JsonElement obj, string name) => + obj.TryGetProperty(name, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String ? v.GetString() ?? "" : ""; + + private static int GetInt(System.Text.Json.JsonElement obj, string name, int fallback) => + obj.TryGetProperty(name, out var v) && v.TryGetInt32(out var i) ? i : fallback; + + private static List GetStringArray(System.Text.Json.JsonElement obj, string name) => + obj.TryGetProperty(name, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Array + ? v.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrWhiteSpace(x)).ToList() + : []; + + private static DateTimeOffset ParseTime(string value) => + DateTimeOffset.TryParse(value, out var dt) ? dt : DateTimeOffset.Now; + + private async Task CompressMemberAsync(string groupId, string userId, CancellationToken ct) + { + var md = await _contextStore.ReadOrCreateMemberAsync(groupId, userId, ct); + var recent = (await _recentLog.ReadRecentAsync(groupId, 160, ct)).Where(m => m.UserId == userId).TakeLast(80).ToList(); + if (recent.Count == 0) return new MemoryRefreshResult { Ok = true, GroupId = groupId, UserId = userId, Changed = false, Message = "没有该群友最近消息" }; + var recentText = string.Join("\n", recent.Select(m => $"[{m.CreatedAt:MM-dd HH:mm}] {m.UserId}: {m.Text}")); + + var system = """ +你是 ShiroBot 的群友记忆整理器。你只负责重写单个群友的 Markdown 记忆文件。 +要求: +- 只保存“长期稳定领域/话题大纲”,不要保存一次性细节、临时链接、单次文件名、短期情绪或刷屏内容。 +- 记忆要粗粒度,用于之后理解偏好和改写 web_search/image_search query;不要写得像聊天流水账。 +- 需要精确时间节点、原始对话、具体决策过程时由重记忆负责,轻记忆只提示“有这个大纲”。 +- 特别留意大的领域偏好:二次元/galgame/具体作品圈、游戏、C#/.NET、QQ 机器人、下载器、音视频、开源库、Web/爬虫、AI 模型等。 +- 搜索消歧只保存能降低歧义的方向,例如“提到 86 时更可能指《爱上火车》八六而不是汽车/动画”,不要保存每次搜索词。 +- 防止过拟合:不要保存用户的措辞习惯、情绪口头禅、临时喜好、某次任务的结论,也不要让记忆规定机器人回答风格。 +- 不要把“用户问过什么”写成“用户偏好什么”;只有多次稳定出现的领域才保留。 +- 使用边界只写负向/约束性信息,例如“不应默认把所有 86 都当成八六,除非上下文在聊二次元/爱上火车”。 +- 不保存隐私敏感信息、账号令牌、银行卡、地址电话等。 +- 如果没有稳定领域信息,就保留“暂无”。不要编造。 +- 输出完整 Markdown,不要解释。 +- 使用固定结构:话题大纲、领域偏好、技术方向、搜索消歧提示、使用边界、注意事项。 +"""; + var prompt = $$""" +现有群友记忆: +{{md}} + +该群友最近消息: +{{recentText}} + +请输出压缩后的完整群友记忆 Markdown。格式必须类似: +# 群友记忆:{{userId}} + +群:{{groupId}} + +## 话题大纲 +- ... + +## 领域偏好 +- ... + +## 技术方向 +- ... + +## 搜索消歧提示 +- ... + +## 使用边界 +- ... + +## 注意事项 +- 轻记忆只保存大纲、长期稳定领域和搜索消歧信息;不要保存回答风格、临时细节、原始对话或隐私信息。 +"""; + var runtime = await _configStore.ReadAsync(ct); + var watch = System.Diagnostics.Stopwatch.StartNew(); + try + { + var result = await _llm.CompleteMemoryAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = prompt } + ], 0.2, 1200, ct); + watch.Stop(); + if (!result.IsFallback && result.Content.StartsWith("#")) + { + var changed = !string.Equals(md.Trim(), result.Content.Trim(), StringComparison.Ordinal); + await _contextStore.WriteMemberAsync(groupId, userId, result.Content, ct); + return new MemoryRefreshResult { Ok = true, GroupId = groupId, UserId = userId, Changed = changed, RecentMessages = recent.Count, Message = $"群友记忆已整理;memoryModel={runtime.OpenAi.MemoryModel};耗时 {watch.ElapsedMilliseconds}ms" }; + } + return new MemoryRefreshResult { Ok = false, GroupId = groupId, UserId = userId, RecentMessages = recent.Count, Changed = false, Message = result.Error ?? "群友记忆整理失败:模型没有返回完整 Markdown" }; + } + catch (OperationCanceledException) + { + watch.Stop(); + return new MemoryRefreshResult { Ok = false, GroupId = groupId, UserId = userId, RecentMessages = recent.Count, Changed = false, Message = $"群友记忆整理被取消/超时;耗时 {watch.ElapsedMilliseconds}ms" }; + } + } +} + +public sealed class MemoryRefreshResult +{ + public bool Ok { get; set; } + public string GroupId { get; set; } = ""; + public string UserId { get; set; } = ""; + public int RecentMessages { get; set; } + public bool Changed { get; set; } + public string Message { get; set; } = ""; +} diff --git a/MyAgent/Agents/MessageFusionAgent.cs b/MyAgent/Agents/MessageFusionAgent.cs new file mode 100644 index 0000000..f910ace --- /dev/null +++ b/MyAgent/Agents/MessageFusionAgent.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using MyAgent.Core; + +namespace MyAgent.Agents; + +/// +/// Fuses short-burst messages into a stable current turn. +/// SessionManager decides which raw messages belong together; this node decides what the burst means. +/// It does not route, plan tools, or answer. +/// +public sealed class MessageFusionAgent(IChatModelClient client, ILogger logger) +{ + public async Task FuseAsync(MessageBatch batch, CancellationToken ct = default) + { + if (batch.Messages.Count <= 1) + { + var text = batch.Messages.LastOrDefault()?.Text.Trim() ?? batch.MergedText.Trim(); + return Build(batch, text, TurnFusionType.Single, "single_message", []); + } + + try + { + var system = """ +你是 QQ 群聊的 MessageFusionAgent,只输出 JSON。 +你的任务是把同一用户短时间内连续发送的多条消息整理成“当前这一轮到底在表达什么”。 +你不判断机器人该不该回复,不规划工具,不回答问题。 + +判断原则: +1. 按时间顺序理解所有消息。 +2. 如果后文明确修正前文,以后文稳定版本为准,但不要丢失必要背景。 +3. 如果后文是在补充前文,把它们合成一个完整问题/请求。 +4. 如果多条消息其实是多个独立问题,shouldSplit=true,并在 stableText 中保留分点。 +5. 如果只是打字拆分,不要过度总结,尽量保留用户原意。 +6. 不要根据关键词表判断,要根据语义关系判断。 + +输出 JSON: +{ + "stableText":"融合后的当前轮文本,给后续 Router/Planner 使用", + "fusionType":"Single|Supplement|Correction|MultiQuestion|AttachmentWithCaption|Ambiguous", + "shouldSplit":false, + "summary":"简短说明融合依据", + "messageRoles":[{"messageId":"...","role":"current|superseded|supplement|correction|question|context"}] +} +"""; + var user = $$""" +同一用户短时间内的连续消息(旧到新): +{{FormatMessages(batch.Messages)}} + +附件数量:{{batch.Messages.Sum(m => m.Attachments.Count)}} + +请输出融合结果 JSON。 +"""; + + var result = await client.CompleteRoutingAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user } + ], 0.0, 360, ct); + + return Parse(batch, result.Content); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Message fusion failed; using raw ordered join"); + return Build(batch, RawJoin(batch.Messages), TurnFusionType.Ambiguous, "fusion_failed:" + ex.Message, []); + } + } + + public static MessageBatch Apply(MessageBatch batch, FusedTurn fused) + { + return new MessageBatch + { + GroupId = batch.GroupId, + UserId = batch.UserId, + ThreadId = batch.ThreadId, + TaskId = batch.TaskId, + Messages = batch.Messages, + MergedText = string.IsNullOrWhiteSpace(fused.StableText) ? batch.MergedText : fused.StableText, + Ready = batch.Ready, + CreatedAt = batch.CreatedAt + }; + } + + private static FusedTurn Parse(MessageBatch batch, string text) + { + using var doc = JsonDocument.Parse(ExtractJson(text)); + var root = doc.RootElement; + var stable = GetString(root, "stableText"); + if (string.IsNullOrWhiteSpace(stable)) stable = RawJoin(batch.Messages); + var type = ParseEnum(root, "fusionType", TurnFusionType.Ambiguous); + var shouldSplit = root.TryGetProperty("shouldSplit", out var ss) && ss.ValueKind is JsonValueKind.True or JsonValueKind.False && ss.GetBoolean(); + var summary = GetString(root, "summary"); + var roles = new List(); + if (root.TryGetProperty("messageRoles", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + foreach (var item in arr.EnumerateArray()) + { + roles.Add(new FusedMessageRole + { + MessageId = GetString(item, "messageId"), + Role = GetString(item, "role") + }); + } + } + var fused = Build(batch, stable.Trim(), type, summary, roles); + fused.ShouldSplit = shouldSplit; + return fused; + } + + private static FusedTurn Build(MessageBatch batch, string stableText, TurnFusionType type, string summary, List roles) + { + return new FusedTurn + { + GroupId = batch.GroupId, + UserId = batch.UserId, + ThreadId = batch.ThreadId, + TaskId = batch.TaskId, + Messages = batch.Messages, + StableText = stableText, + FusionSummary = summary, + FusionType = type, + MessageRoles = roles + }; + } + + private static string FormatMessages(IReadOnlyList messages) + { + return string.Join("\n", messages.Select((m, i) => $"{i + 1}. id={m.MessageId}; time={m.CreatedAt:HH:mm:ss}; text={m.Text}")); + } + + private static string RawJoin(IReadOnlyList messages) + { + return string.Join("\n", messages.Select((m, i) => messages.Count == 1 ? m.Text.Trim() : $"{i + 1}. {m.Text.Trim()}")); + } + + private static string GetString(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; + private static T ParseEnum(JsonElement root, string name, T fallback) where T : struct => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String && Enum.TryParse(v.GetString(), true, out var parsed) ? parsed : fallback; + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + return start >= 0 && end > start ? text[start..(end + 1)] : text; + } +} diff --git a/MyAgent/Agents/OpenAiChatClient.cs b/MyAgent/Agents/OpenAiChatClient.cs new file mode 100644 index 0000000..5c9285c --- /dev/null +++ b/MyAgent/Agents/OpenAiChatClient.cs @@ -0,0 +1,716 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Agents; + +public sealed class OpenAiChatClient(IOptions options, LocalConfigStore localConfig, ILogger logger) : IChatModelClient +{ + private readonly ShirobotOptions _options = options.Value; + private readonly LocalConfigStore _localConfig = localConfig; + private readonly HttpClient _http = new(); + private readonly ILogger _logger = logger; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + + public async Task CompleteAsync(IEnumerable messages, double temperature, int maxTokens = 1200, CancellationToken ct = default, string? modelOverride = null) + { + var cfg = await _localConfig.ReadAsync(ct); + var apiKey = cfg.OpenAi.ApiKey; + var baseUrl = cfg.OpenAi.BaseUrl.TrimEnd('/'); + var model = string.IsNullOrWhiteSpace(modelOverride) ? cfg.OpenAi.Model : modelOverride; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) + { + return new LlmResult + { + IsFallback = true, + Error = "OpenAI-compatible Base URL or model is not configured.", + Content = "[模型配置不完整,当前是模拟回复] 我已经收到问题,会根据群上下文和最近消息回答。" + }; + } + + var url = $"{baseUrl}/chat/completions"; + + var payload = new JsonObject + { + ["model"] = model, + ["messages"] = BuildChatMessages(messages), + ["temperature"] = temperature, + ["max_tokens"] = maxTokens + }; + + var jsonPayload = payload.ToJsonString(JsonOptions); + + try + { + using var resp = await SendChatAsync(url, apiKey, jsonPayload, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + // 兼容用户填 proxy root 的情况:root 暴露 /v1/chat/completions,但配置里没写 /v1。 + if ((resp.StatusCode == System.Net.HttpStatusCode.NotFound || resp.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed) + && !baseUrl.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + using var resp2 = await SendChatAsync($"{baseUrl}/v1/chat/completions", apiKey, jsonPayload, ct); + body = await resp2.Content.ReadAsStringAsync(ct); + if (!resp2.IsSuccessStatusCode) + { + return new LlmResult + { + IsFallback = true, + Error = $"LLM HTTP {(int)resp2.StatusCode}: {body}", + Content = "模型接口暂时不可用,我先给个保守建议:请稍后重试,或检查 API 配置。" + }; + } + } + else if (!resp.IsSuccessStatusCode) + { + return new LlmResult + { + IsFallback = true, + Error = $"LLM HTTP {(int)resp.StatusCode}: {body}", + Content = "模型接口暂时不可用,我先给个保守建议:请稍后重试,或检查 API 配置。" + }; + } + + using var doc = JsonDocument.Parse(body); + var content = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? ""; + return new LlmResult { Content = content.Trim() }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Chat completion failed"); + return new LlmResult + { + IsFallback = true, + Error = ex.Message, + Content = "模型调用失败了,我先不乱答;可以稍后再问一次。" + }; + } + } + + public async Task CreateEmbeddingAsync(string input, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + var apiKey = cfg.OpenAi.ApiKey; + var baseUrl = cfg.OpenAi.BaseUrl.TrimEnd('/'); + var model = cfg.OpenAi.EmbeddingModel; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model) || string.IsNullOrWhiteSpace(input)) return []; + + var payload = new JsonObject + { + ["model"] = model, + ["input"] = input + }; + var jsonPayload = payload.ToJsonString(JsonOptions); + + try + { + var body = await SendEmbeddingWithFallbackAsync(baseUrl, apiKey, jsonPayload, ct); + if (!body.Ok) + { + _logger.LogDebug("Embedding HTTP {Status}: {Body}", body.StatusCode, body.Body); + return []; + } + + using var doc = JsonDocument.Parse(body.Body); + var arr = doc.RootElement.GetProperty("data")[0].GetProperty("embedding"); + var vector = new float[arr.GetArrayLength()]; + var i = 0; + foreach (var v in arr.EnumerateArray()) vector[i++] = v.GetSingle(); + return vector; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Embedding failed"); + return []; + } + } + + public async Task CompleteRoutingAsync(IEnumerable messages, double temperature, int maxTokens = 500, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + return await CompleteAsync(messages, temperature, maxTokens, ct, cfg.OpenAi.RouterModel); + } + + public async Task CompleteReplyAsync(IEnumerable messages, double temperature, int maxTokens = 1200, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + return await CompleteAsync(messages, temperature, maxTokens, ct, cfg.OpenAi.ReplyModel); + } + + public async Task CompleteMemoryAsync(IEnumerable messages, double temperature, int maxTokens = 2000, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + return await CompleteAsync(messages, temperature, maxTokens, ct, cfg.OpenAi.MemoryModel); + } + + public async Task CompleteWithWebToolAsync( + IEnumerable messages, + double temperature, + IWebSearchClient webSearch, + int maxSearchResults, + int maxTokens = 1200, + bool forceWebSearch = false, + string? fallbackQuery = null, + CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + var apiKey = cfg.OpenAi.ApiKey; + var baseUrl = cfg.OpenAi.BaseUrl.TrimEnd('/'); + var toolModel = cfg.OpenAi.ToolModel; + var replyModel = cfg.OpenAi.ReplyModel; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(toolModel) || string.IsNullOrWhiteSpace(replyModel)) + { + return new LlmResult + { + IsFallback = true, + Error = "OpenAI-compatible Base URL, tool model, or reply model is not configured.", + Content = "[模型配置不完整,当前是模拟回复] 我已经收到问题,会根据群上下文和最近消息回答。" + }; + } + + var trace = new List(); + var sourceMessages = messages.ToList(); + var toolResultTexts = new List(); + var chatMessages = new JsonArray(); + foreach (var m in sourceMessages) + { + chatMessages.Add(new JsonObject + { + ["role"] = m.Role, + ["content"] = m.Content + }); + } + + try + { + var firstBody = await SendChatPayloadWithFallbackAsync(baseUrl, apiKey, BuildPayload(toolModel, chatMessages, temperature, maxTokens, includeWebTool: true, forceWebTool: forceWebSearch), ct); + if (!firstBody.Ok) + { + // 很多 OpenAI-compatible proxy 不支持 tools/tool_choice。 + // 这种情况下不要直接失败,降级为“后端代模型搜索 -> 再让模型总结”。 + if (LooksLikeToolUnsupported(firstBody.Body)) + { + return await CompleteWithServerSideWebFallbackAsync(messages, temperature, webSearch, maxSearchResults, maxTokens, fallbackQuery, trace, ct); + } + + return new LlmResult + { + IsFallback = true, + Error = $"LLM HTTP {firstBody.StatusCode}: {firstBody.Body}", + Content = "模型接口暂时不可用,我先给个保守建议:请稍后重试,或检查 API 配置。", + Trace = trace + }; + } + + using var firstDoc = JsonDocument.Parse(firstBody.Body); + var msg = firstDoc.RootElement.GetProperty("choices")[0].GetProperty("message"); + if (!msg.TryGetProperty("tool_calls", out var toolCalls) || toolCalls.ValueKind != JsonValueKind.Array || toolCalls.GetArrayLength() == 0) + { + trace.Add(new AgentTraceEvent + { + Step = "tool-call", + Agent = "web_search", + Status = forceWebSearch ? "missing" : "not_called", + Detail = forceWebSearch ? "route required web search, but model returned no tool_calls" : "model decided not to call web_search" + }); + var content = msg.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(content)) + { + trace.Add(new AgentTraceEvent + { + Step = "reply-empty", + Agent = "reply-agent", + Status = "fallback", + Detail = "model returned empty content before tool execution; retrying without tools" + }); + var retry = await CompleteReplyAsync(sourceMessages, temperature, maxTokens, ct); + retry.Trace.AddRange(trace); + return retry; + } + var noToolReply = await CompleteReplyAsync(sourceMessages, temperature, maxTokens, ct); + noToolReply.Trace.AddRange(trace); + return noToolReply; + } + + chatMessages.Add(JsonNode.Parse(msg.GetRawText())); + + foreach (var call in toolCalls.EnumerateArray()) + { + var id = call.GetProperty("id").GetString() ?? Guid.NewGuid().ToString("n"); + var fn = call.GetProperty("function"); + var name = fn.GetProperty("name").GetString() ?? ""; + var argsText = fn.TryGetProperty("arguments", out var a) ? a.GetString() ?? "{}" : "{}"; + var query = ExtractToolQuery(argsText); + + if (!string.Equals(name, "web_search", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(query)) + { + chatMessages.Add(new JsonObject + { + ["role"] = "tool", + ["tool_call_id"] = id, + ["content"] = "Unsupported tool call or empty query." + }); + trace.Add(new AgentTraceEvent { Step = "tool-call", Agent = "reply-agent", Status = "error", Detail = $"unsupported tool={name}" }); + continue; + } + + var watch = System.Diagnostics.Stopwatch.StartNew(); + var search = await webSearch.SearchAsync(query, maxSearchResults, ct); + watch.Stop(); + trace.Add(new AgentTraceEvent + { + Step = "tool-call", + Agent = "web_search", + Status = string.IsNullOrWhiteSpace(search.Error) ? "ok" : "error", + Detail = string.IsNullOrWhiteSpace(search.Error) ? $"query={query}, items={search.Items.Count}" : search.Error, + ElapsedMs = watch.ElapsedMilliseconds + }); + + var formattedSearch = FormatSearchResultForTool(search); + toolResultTexts.Add(formattedSearch); + chatMessages.Add(new JsonObject + { + ["role"] = "tool", + ["tool_call_id"] = id, + ["content"] = formattedSearch + }); + } + + var finalBody = await SendChatPayloadWithFallbackAsync(baseUrl, apiKey, BuildPayload(replyModel, chatMessages, temperature, maxTokens, includeWebTool: false), ct); + if (!finalBody.Ok) + { + return new LlmResult + { + IsFallback = true, + Error = $"LLM HTTP {finalBody.StatusCode}: {finalBody.Body}", + Content = "模型工具调用后生成回答失败了;可以稍后重试。", + Trace = trace + }; + } + + using var finalDoc = JsonDocument.Parse(finalBody.Body); + var finalContent = finalDoc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? ""; + if (string.IsNullOrWhiteSpace(finalContent)) + { + trace.Add(new AgentTraceEvent + { + Step = "reply-empty", + Agent = "reply-agent", + Status = "fallback", + Detail = "model returned empty content after tool call; retrying with search results as plain context" + }); + + var augmented = sourceMessages.ToList(); + augmented.Add(new ChatMessage + { + Role = "user", + Content = "模型刚才调用了联网搜索,但最终回答为空。请直接基于以下搜索结果回答用户问题,不要再调用工具。\n\n" + string.Join("\n\n", toolResultTexts) + }); + var retry = await CompleteReplyAsync(augmented, temperature, maxTokens, ct); + retry.Trace.AddRange(trace); + return retry; + } + return new LlmResult { Content = finalContent.Trim(), Trace = trace }; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "Chat completion with web tool failed"); + return new LlmResult + { + IsFallback = true, + Error = ex.Message, + Content = "模型调用失败了,我先不乱答;可以稍后再问一次。", + Trace = trace + }; + } + } + + private async Task CompleteWithServerSideWebFallbackAsync( + IEnumerable messages, + double temperature, + IWebSearchClient webSearch, + int maxSearchResults, + int maxTokens, + string? fallbackQuery, + List trace, + CancellationToken ct) + { + var query = string.IsNullOrWhiteSpace(fallbackQuery) + ? messages.LastOrDefault(x => x.Role == "user")?.Content ?? "" + : fallbackQuery; + + var watch = System.Diagnostics.Stopwatch.StartNew(); + var search = await webSearch.SearchAsync(query, maxSearchResults, ct); + watch.Stop(); + trace.Add(new AgentTraceEvent + { + Step = "tool-call", + Agent = "web_search", + Status = string.IsNullOrWhiteSpace(search.Error) ? "fallback_ok" : "fallback_error", + Detail = string.IsNullOrWhiteSpace(search.Error) + ? $"server-side fallback because model/proxy does not support tools; query={query}, items={search.Items.Count}" + : search.Error, + ElapsedMs = watch.ElapsedMilliseconds + }); + + var augmented = messages.ToList(); + augmented.Add(new ChatMessage + { + Role = "user", + Content = "以下是后端代模型执行的联网搜索结果。请基于这些结果回答;如果结果不足,请说明不确定。\n\n" + FormatSearchResultForTool(search) + }); + + var result = await CompleteReplyAsync(augmented, temperature, maxTokens, ct); + result.Trace.AddRange(trace); + return result; + } + + private static bool LooksLikeToolUnsupported(string body) + { + return body.Contains("tool", StringComparison.OrdinalIgnoreCase) + || body.Contains("function", StringComparison.OrdinalIgnoreCase) + || body.Contains("unsupported", StringComparison.OrdinalIgnoreCase) + || body.Contains("tool_choice", StringComparison.OrdinalIgnoreCase) + || body.Contains("tools", StringComparison.OrdinalIgnoreCase); + } + + private static JsonObject BuildPayload(string model, JsonArray messages, double temperature, int maxTokens, bool includeWebTool, bool forceWebTool = false) + { + var payload = new JsonObject + { + ["model"] = model, + ["messages"] = messages.DeepClone(), + ["temperature"] = temperature, + ["max_tokens"] = maxTokens + }; + + if (includeWebTool) + { + payload["tools"] = new JsonArray + { + new JsonObject + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = "web_search", + ["description"] = "Search the web for current or uncertain facts. Use for real-time info, ACG/game news, software compatibility/version support, official docs, or when the prompt asks to search.", + ["parameters"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query" }, + ["reason"] = new JsonObject { ["type"] = "string", ["description"] = "Why web search is needed" } + }, + ["required"] = new JsonArray { "query" } + } + } + } + }; + if (forceWebTool) + payload["tool_choice"] = new JsonObject { ["type"] = "function", ["function"] = new JsonObject { ["name"] = "web_search" } }; + else + payload["tool_choice"] = "auto"; + } + return payload; + } + + private async Task<(bool Ok, int StatusCode, string Body)> SendChatPayloadWithFallbackAsync(string baseUrl, string? apiKey, JsonObject payload, CancellationToken ct) + { + var json = payload.ToJsonString(JsonOptions); + using var resp = await SendChatAsync($"{baseUrl}/chat/completions", apiKey, json, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if ((resp.StatusCode == System.Net.HttpStatusCode.NotFound || resp.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed) + && !baseUrl.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + using var resp2 = await SendChatAsync($"{baseUrl}/v1/chat/completions", apiKey, json, ct); + var body2 = await resp2.Content.ReadAsStringAsync(ct); + return (resp2.IsSuccessStatusCode, (int)resp2.StatusCode, body2); + } + return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body); + } + + private static string ExtractToolQuery(string argsText) + { + try + { + using var doc = JsonDocument.Parse(argsText); + return doc.RootElement.TryGetProperty("query", out var q) ? q.GetString() ?? "" : ""; + } + catch { return ""; } + } + + private static string FormatSearchResultForTool(WebSearchResult result) + { + if (!string.IsNullOrWhiteSpace(result.Error)) return "Search error: " + result.Error; + if (result.Items.Count == 0) return "No search results."; + var sb = new StringBuilder(); + sb.AppendLine("Search query: " + result.Query); + for (var i = 0; i < result.Items.Count; i++) + { + var item = result.Items[i]; + sb.AppendLine($"\n[{i + 1}] {item.Title}"); + if (!string.IsNullOrWhiteSpace(item.Url)) sb.AppendLine(item.Url); + if (!string.IsNullOrWhiteSpace(item.Text)) sb.AppendLine(item.Text.Length > 1200 ? item.Text[..1200] + "..." : item.Text); + } + return sb.ToString(); + } + + public async Task PlanToolCallsAsync( + IEnumerable messages, + double temperature, + int maxTokens = 500, + CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + var apiKey = cfg.OpenAi.ApiKey; + var baseUrl = cfg.OpenAi.BaseUrl.TrimEnd('/'); + var model = cfg.OpenAi.ToolModel; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) + return new ToolPlanningResult { Error = "OpenAI-compatible Base URL or tool model is not configured." }; + + var chatMessages = BuildChatMessages(messages); + + var payload = new JsonObject + { + ["model"] = model, + ["messages"] = chatMessages, + ["temperature"] = temperature, + ["max_tokens"] = maxTokens, + ["tools"] = BuildShiroTools(), + ["tool_choice"] = "auto" + }; + + var body = await SendChatPayloadWithFallbackAsync(baseUrl, apiKey, payload, ct); + if (!body.Ok) + return new ToolPlanningResult { Error = $"LLM HTTP {body.StatusCode}: {body.Body}" }; + + try + { + using var doc = JsonDocument.Parse(body.Body); + var msg = doc.RootElement.GetProperty("choices")[0].GetProperty("message"); + var result = new ToolPlanningResult + { + Content = msg.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "" + }; + if (msg.TryGetProperty("tool_calls", out var calls) && calls.ValueKind == JsonValueKind.Array) + { + foreach (var call in calls.EnumerateArray()) + { + if (!call.TryGetProperty("function", out var fn)) continue; + result.ToolCalls.Add(new PlannedToolCall + { + Id = call.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("n") : Guid.NewGuid().ToString("n"), + Name = fn.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "", + ArgumentsJson = fn.TryGetProperty("arguments", out var args) ? args.GetString() ?? "{}" : "{}" + }); + } + } + return result; + } + catch (Exception ex) + { + return new ToolPlanningResult { Error = ex.Message }; + } + } + + private static JsonArray BuildShiroTools() => new() + { + new JsonObject + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = "web_search", + ["description"] = "Search the web for current/uncertain facts, recent events, official docs, software/platform/version info, prices, ACG/game news, exact project URLs, GitHub repositories, NuGet packages, docs, download links, source citations, or anything that needs verification.", + ["parameters"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query" }, + ["maxResults"] = new JsonObject { ["type"] = "integer", ["description"] = "1-10 results" }, + ["reason"] = new JsonObject { ["type"] = "string", ["description"] = "Why search is useful" } + }, + ["required"] = new JsonArray { "query" } + } + } + }, + new JsonObject + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = "image_search", + ["description"] = "Find real image URLs and cache image attachments. Use when the user asks for images/photos/wallpapers/avatars/screenshots/covers/visual references, and optionally for ACG/anime/galgame/game/character/model appearance topics where images would help.", + ["parameters"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Image search query" }, + ["maxImages"] = new JsonObject { ["type"] = "integer", ["description"] = "1-3 images to attach" }, + ["reason"] = new JsonObject { ["type"] = "string", ["description"] = "Why images are useful" } + }, + ["required"] = new JsonArray { "query" } + } + } + } + }; + + public async IAsyncEnumerable StreamCompleteAsync( + IEnumerable messages, + double temperature, + int maxTokens = 1200, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + var apiKey = cfg.OpenAi.ApiKey; + var baseUrl = cfg.OpenAi.BaseUrl.TrimEnd('/'); + var model = cfg.OpenAi.ReplyModel; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) + throw new InvalidOperationException("OpenAI-compatible Base URL or reply model is not configured."); + + var payload = new JsonObject + { + ["model"] = model, + ["messages"] = BuildChatMessages(messages), + ["temperature"] = temperature, + ["max_tokens"] = maxTokens, + ["stream"] = true + }; + var jsonPayload = payload.ToJsonString(JsonOptions); + + using var resp = await SendChatStreamWithFallbackAsync(baseUrl, apiKey, jsonPayload, ct); + resp.EnsureSuccessStatusCode(); + + await using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + continue; + + var data = line[5..].Trim(); + if (data == "[DONE]") yield break; + + string? delta = null; + try + { + using var doc = JsonDocument.Parse(data); + var choice = doc.RootElement.GetProperty("choices")[0]; + if (choice.TryGetProperty("delta", out var d) && d.TryGetProperty("content", out var c)) + delta = c.GetString(); + else if (choice.TryGetProperty("message", out var m) && m.TryGetProperty("content", out var mc)) + delta = mc.GetString(); + } + catch + { + // Ignore malformed SSE keepalive/proxy lines. + } + + if (!string.IsNullOrEmpty(delta)) yield return delta; + } + } + + private static JsonArray BuildChatMessages(IEnumerable messages) + { + var arr = new JsonArray(); + foreach (var m in messages) + { + if (m.ImageUrls.Count == 0) + { + arr.Add(new JsonObject { ["role"] = m.Role, ["content"] = m.Content }); + continue; + } + + var content = new JsonArray + { + new JsonObject { ["type"] = "text", ["text"] = m.Content } + }; + foreach (var rawUrl in m.ImageUrls.Where(x => !string.IsNullOrWhiteSpace(x)).Take(8)) + { + var url = NormalizeOpenAiImageUrl(rawUrl); + content.Add(new JsonObject + { + ["type"] = "image_url", + ["image_url"] = new JsonObject { ["url"] = url } + }); + } + arr.Add(new JsonObject { ["role"] = m.Role, ["content"] = content }); + } + return arr; + } + + private static string NormalizeOpenAiImageUrl(string url) + { + // Some OpenAI-compatible proxies reject raw base64:// or provider-specific schemes. + // Platform image data may use base64://, while Chat Completions vision expects data:image/...;base64,... or http(s). + if (url.StartsWith("base64://", StringComparison.OrdinalIgnoreCase)) + return "data:image/jpeg;base64," + url[9..]; + return url; + } + + private async Task<(bool Ok, int StatusCode, string Body)> SendEmbeddingWithFallbackAsync(string baseUrl, string? apiKey, string jsonPayload, CancellationToken ct) + { + using var resp = await SendJsonAsync($"{baseUrl}/embeddings", apiKey, jsonPayload, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if ((resp.StatusCode == System.Net.HttpStatusCode.NotFound || resp.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed) + && !baseUrl.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + using var resp2 = await SendJsonAsync($"{baseUrl}/v1/embeddings", apiKey, jsonPayload, ct); + var body2 = await resp2.Content.ReadAsStringAsync(ct); + return (resp2.IsSuccessStatusCode, (int)resp2.StatusCode, body2); + } + return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body); + } + + private async Task SendJsonAsync(string url, string? apiKey, string jsonPayload, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + if (!string.IsNullOrWhiteSpace(apiKey)) + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + req.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + return await _http.SendAsync(req, ct); + } + + private async Task SendChatStreamWithFallbackAsync(string baseUrl, string? apiKey, string jsonPayload, CancellationToken ct) + { + var resp = await SendChatAsync($"{baseUrl}/chat/completions", apiKey, jsonPayload, ct, HttpCompletionOption.ResponseHeadersRead); + if ((resp.StatusCode == System.Net.HttpStatusCode.NotFound || resp.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed) + && !baseUrl.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + resp.Dispose(); + return await SendChatAsync($"{baseUrl}/v1/chat/completions", apiKey, jsonPayload, ct, HttpCompletionOption.ResponseHeadersRead); + } + return resp; + } + + private async Task SendChatAsync( + string url, + string? apiKey, + string jsonPayload, + CancellationToken ct, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + if (!string.IsNullOrWhiteSpace(apiKey)) + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + req.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + return await _http.SendAsync(req, completionOption, ct); + } +} diff --git a/MyAgent/Agents/OpenAiModelService.cs b/MyAgent/Agents/OpenAiModelService.cs new file mode 100644 index 0000000..9cc263c --- /dev/null +++ b/MyAgent/Agents/OpenAiModelService.cs @@ -0,0 +1,85 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using MyAgent.Storage; + +namespace MyAgent.Agents; + +public sealed class OpenAiModelService(LocalConfigStore localConfig, ILogger logger) +{ + private readonly LocalConfigStore _localConfig = localConfig; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = new(); + + public async Task ListAsync(string? baseUrl = null, string? apiKey = null, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + baseUrl = string.IsNullOrWhiteSpace(baseUrl) ? cfg.OpenAi.BaseUrl : baseUrl; + apiKey = string.IsNullOrWhiteSpace(apiKey) ? cfg.OpenAi.ApiKey : apiKey; + + if (string.IsNullOrWhiteSpace(baseUrl)) + return new OpenAiModelsResponse { Error = "Base URL is empty." }; + + baseUrl = baseUrl.TrimEnd('/'); + var url = baseUrl + "/models"; + + try + { + using var resp = await SendModelsAsync(url, apiKey, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + // 兼容 CLI Proxy 根地址:根返回 { endpoints: ["GET /v1/models"] },实际 models 在 /v1/models。 + if ((resp.StatusCode == System.Net.HttpStatusCode.NotFound || resp.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed) + && !baseUrl.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + using var resp2 = await SendModelsAsync(baseUrl + "/v1/models", apiKey, ct); + body = await resp2.Content.ReadAsStringAsync(ct); + if (!resp2.IsSuccessStatusCode) + return new OpenAiModelsResponse { BaseUrl = baseUrl, Error = $"HTTP {(int)resp2.StatusCode}: {body}" }; + } + else if (!resp.IsSuccessStatusCode) + { + return new OpenAiModelsResponse { BaseUrl = baseUrl, Error = $"HTTP {(int)resp.StatusCode}: {body}" }; + } + + using var doc = JsonDocument.Parse(body); + var models = new List(); + if (doc.RootElement.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) + { + foreach (var item in data.EnumerateArray()) + { + if (item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String) + { + var value = id.GetString(); + if (!string.IsNullOrWhiteSpace(value)) models.Add(value); + } + } + } + + return new OpenAiModelsResponse + { + BaseUrl = baseUrl, + Models = models.Distinct().OrderBy(x => x).ToList() + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to list OpenAI-compatible models"); + return new OpenAiModelsResponse { BaseUrl = baseUrl, Error = ex.Message }; + } + } + + private async Task SendModelsAsync(string url, string? apiKey, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + if (!string.IsNullOrWhiteSpace(apiKey)) + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + return await _http.SendAsync(req, ct); + } +} + +public sealed class OpenAiModelsResponse +{ + public string BaseUrl { get; set; } = ""; + public List Models { get; set; } = []; + public string? Error { get; set; } +} diff --git a/MyAgent/Agents/PlannerAgent.cs b/MyAgent/Agents/PlannerAgent.cs new file mode 100644 index 0000000..b186c0c --- /dev/null +++ b/MyAgent/Agents/PlannerAgent.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Agents; + +/// +/// Planner node. It does not manage workflow state, does not execute tools, and does not write final replies. +/// It reads the Router decision plus compact context and returns the next action, task type, tool plan, +/// analysis need, notices, and reply budget. +/// +public sealed class PlannerAgent( + IChatModelClient client, + ConversationRegistry conversations, + RecentLogStore recentLog, + LocalConfigStore configStore, + ToolCatalog toolCatalog, + SubagentRegistry subagentRegistry, + ExecutionPlanCompiler planCompiler, + ILogger logger) +{ + public async Task RunAsync(MessageBatch batch, RouterDecisionEnvelope router, CancellationToken ct = default) + { + if (!router.ShouldReply) + { + return new PlannerDecisionEnvelope + { + NextAction = PlannerNextAction.AbortNoReply, + TaskType = AgentTaskType.Chat, + Complexity = router.Complexity, + Reason = router.Reason, + Route = router.Route + }; + } + + var cfg = await configStore.ReadAsync(ct); + var routingContext = await conversations.BuildRoutingContextTextAsync(batch.ThreadId, batch.MergedText, ct, maxRecentMessages: 3, maxTurns: 1); + var attachmentContext = await BuildAttachmentContextAsync(batch, ct); + + var system = $$""" +你是 QQ 群聊 AI 的 PlannerAgent,只输出紧凑 JSON。 +你不回答用户;只决定:直接回复、调用工具、分析、澄清或放弃。 +机器人:{{cfg.Bot.BotDisplayName}} +可用工具能力: +{{toolCatalog.FormatForPlanner()}} +受控执行角色: +{{subagentRegistry.FormatForPlanner()}} +规则: +1. Router 已判断应该进入流程,不要重复判断插嘴。 +2. 不要用关键词命中决定工具。结合用户真正目标、信息时效性、可验证性、已有上下文和工具收益进行语义判断。 +3. 输出 toolWeight、memoryRecallWeight、analysisWeight,范围 0~1,表示调用对应能力能实质改善回答的概率/收益。 +4. 每个工具都输出 confidence。只有语义目标和输入参数明确时才给高权重;单个词出现不构成调用理由。 +5. web_fetch 只读取消息中真实存在的 URL;web_search 用模型生成的完整检索 query;image_search 的 query 必须包含可辨识实体和视觉目标。 +6. 图片附件相关问题要特别谨慎:Planner 看不到图片像素,只能看到附件元数据;没有明确外部检索目标时交给视觉回复模型。 +7. 普通聊天、已有上下文足够或工具收益低时 ReadyToReply 且 tools=[]。 +8. 只有多来源综合、冲突核验或高风险决策确实需要独立推理时才提高 analysisWeight。 +9. memoryRecallWeight 只衡量是否需要读取过去群聊记忆;普通时间表达、一般背景叙述不应自动触发。 +只输出 JSON,不要解释。 +字段:nextAction, taskType, complexity, toolWeight, memoryRecallWeight, analysisWeight, reason, userVisibleNotice, toolDoneNotice, analysisNotice, maxReplySegments, tools。 +工具格式:{"name":"{{toolCatalog.AllowedToolNamesForPrompt()}}","query":"","url":"","reason":"","maxResults":5,"confidence":0.0} +"""; + + var user = $$""" +Router: shouldReply={{router.ShouldReply}}, target={{router.Target}}, risk={{router.InterjectionRisk}}, complexity={{router.Complexity}}, reason={{router.Reason}} +最近上下文: +{{Limit(routingContext, 900)}} +当前消息: +{{batch.MergedText}} +附件上下文: +{{attachmentContext.Text}} +"""; + + try + { + var result = await client.CompleteRoutingAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user } + ], 0.0, 420, ct); + var plan = ParsePlan(result.Content, router, toolCatalog); + NormalizeImageAttachmentPlan(plan, attachmentContext); + NormalizeToolSecurity(plan, attachmentContext.AllowedFetchUrls); + plan.Route = BuildRoute(router.Route, plan); + plan.ExecutionPlan = planCompiler.Compile(plan); + return plan; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "PlannerAgent failed; falling back to direct reply"); + return Fallback(router, "planner_failed:" + ex.Message); + } + } + + private async Task BuildAttachmentContextAsync(MessageBatch batch, CancellationToken ct) + { + var attachments = batch.Messages.SelectMany(m => m.Attachments).ToList(); + var repliedAttachments = new List(); + foreach (var msg in batch.Messages) + { + if (string.IsNullOrWhiteSpace(msg.ReplyToMessageId)) continue; + var replied = await recentLog.FindByMessageIdAsync(batch.GroupId, msg.ReplyToMessageId, ct); + if (replied is not null && replied.Attachments.Count > 0) repliedAttachments.AddRange(replied.Attachments); + } + + var all = attachments.Concat(repliedAttachments).ToList(); + var allowedFetchUrls = ExtractHttpUrls(batch.MergedText); + foreach (var msg in batch.Messages) + { + if (string.IsNullOrWhiteSpace(msg.ReplyToMessageId)) continue; + var replied = await recentLog.FindByMessageIdAsync(batch.GroupId, msg.ReplyToMessageId, ct); + if (replied is not null) + foreach (var url in ExtractHttpUrls(replied.Text)) allowedFetchUrls.Add(url); + } + if (all.Count == 0) return new AttachmentPlanningContext("无附件。", HasImage: false, HasExtractedText: false, allowedFetchUrls); + var imageCount = all.Count(a => string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase)); + var fileCount = all.Count(a => !string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase)); + var lines = new List + { + $"当前消息附件={attachments.Count},被回复消息附件={repliedAttachments.Count},图片={imageCount},文件={fileCount}。", + "注意:Planner 只能看到附件元数据,看不到图片像素;图片内容由后续视觉回复模型读取。" + }; + foreach (var a in all.Take(6)) + { + lines.Add($"- type={a.Type}; title={Safe(a.Title)}; mime={Safe(a.Mime)}; size={a.Size}; text={Safe(Limit(a.TextContent, 160))}"); + } + return new AttachmentPlanningContext( + string.Join("\n", lines), + HasImage: imageCount > 0, + HasExtractedText: all.Any(a => !string.IsNullOrWhiteSpace(a.TextContent)), + allowedFetchUrls); + } + + private static void NormalizeImageAttachmentPlan(PlannerDecisionEnvelope plan, AttachmentPlanningContext attachmentContext) + { + // Planner cannot see pixels. For image-only questions, running web/image search before the vision model + // often produces bad queries from words like “这个/图片/截图”. Prefer direct vision first unless the + // attachment already carries extracted text or the tool is a concrete URL fetch. + if (!attachmentContext.HasImage || attachmentContext.HasExtractedText || !plan.NeedsTool) return; + if (plan.Tools.Any(t => string.Equals(t.Name, "web_fetch", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(t.Url))) return; + + plan.NextAction = PlannerNextAction.ReadyToReply; + plan.NeedsTool = false; + plan.NeedsAnalysis = false; + plan.Tools.Clear(); + plan.Reason = string.IsNullOrWhiteSpace(plan.Reason) + ? "图片附件问题先交给视觉回复模型观察,不预先搜索" + : plan.Reason + "; image_attachment_vision_first"; + } + + private static void NormalizeToolSecurity(PlannerDecisionEnvelope plan, IReadOnlySet allowedFetchUrls) + { + plan.Tools.RemoveAll(tool => string.Equals(tool.Name, "web_fetch", StringComparison.OrdinalIgnoreCase) + && !allowedFetchUrls.Contains(NormalizeUrl(tool.Url))); + if (plan.Tools.Count == 0 && plan.NeedsTool) + { + plan.NeedsTool = false; + if (plan.NextAction == PlannerNextAction.NeedTool) plan.NextAction = PlannerNextAction.ReadyToReply; + plan.Reason += "; rejected_untrusted_or_invalid_tool_input"; + } + } + + private static HashSet ExtractHttpUrls(string text) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (System.Text.RegularExpressions.Match match in System.Text.RegularExpressions.Regex.Matches(text ?? "", @"https?://[^\s<>]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + var normalized = NormalizeUrl(match.Value.TrimEnd('.', ',', ',', '。', ')', ')')); + if (!string.IsNullOrWhiteSpace(normalized)) result.Add(normalized); + } + return result; + } + + private static string NormalizeUrl(string url) => Uri.TryCreate(url, UriKind.Absolute, out var parsed) && parsed.Scheme is "http" or "https" + ? parsed.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped) + : ""; + + private sealed record AttachmentPlanningContext(string Text, bool HasImage, bool HasExtractedText, IReadOnlySet AllowedFetchUrls); + + private static string Safe(string? value) => string.IsNullOrWhiteSpace(value) ? "无" : value.Trim(); + + internal static PlannerDecisionEnvelope ParsePlan(string text, RouterDecisionEnvelope router, ToolCatalog toolCatalog) + { + using var doc = JsonDocument.Parse(ExtractJson(text)); + var root = doc.RootElement; + var legacyNeedsTool = root.TryGetProperty("needsTool", out var legacyTool) + && legacyTool.ValueKind is JsonValueKind.True or JsonValueKind.False + && legacyTool.GetBoolean(); + var hasToolWeight = root.TryGetProperty("toolWeight", out _); + var toolWeight = GetDouble(root, "toolWeight", legacyNeedsTool ? 1 : 0); + var tools = new List(); + if (root.TryGetProperty("tools", out var toolArray) && toolArray.ValueKind == JsonValueKind.Array) + { + foreach (var item in toolArray.EnumerateArray()) + { + var name = GetString(item, "name"); + if (string.IsNullOrWhiteSpace(name) || !toolCatalog.TryGet(name, out var capability)) continue; + var confidence = GetDouble(item, "confidence", toolWeight); + if (!IntentWeightPolicy.ShouldUseTool(confidence)) continue; + var query = GetString(item, "query"); + var url = GetString(item, "url"); + if (!HasValidToolInput(name, query, url)) continue; + tools.Add(new RouteToolRequest + { + Name = name, + Query = query, + Url = url, + Reason = GetString(item, "reason"), + MaxResults = item.TryGetProperty("maxResults", out var mr) && mr.TryGetInt32(out var n) ? Math.Clamp(n, 1, 10) : capability.DefaultMaxResults, + Confidence = IntentWeightPolicy.Normalize(confidence) + }); + } + } + + tools = tools.OrderByDescending(x => x.Confidence).Take(4).ToList(); + if (!hasToolWeight && !legacyNeedsTool && tools.Count > 0) toolWeight = tools.Max(x => x.Confidence); + + var memoryRecallWeight = GetDouble(root, "memoryRecallWeight", 0); + var analysisWeight = GetDouble(root, "analysisWeight", 0); + var needsTool = IntentWeightPolicy.ShouldUseTool(toolWeight) && tools.Count > 0; + if (!needsTool) tools.Clear(); + var nextAction = ParseEnum(root, "nextAction", !router.ShouldReply ? PlannerNextAction.AbortNoReply : needsTool ? PlannerNextAction.NeedTool : PlannerNextAction.ReadyToReply); + if (nextAction == PlannerNextAction.NeedTool && !needsTool) nextAction = PlannerNextAction.ReadyToReply; + return new PlannerDecisionEnvelope + { + NextAction = nextAction, + TaskType = ParseEnum(root, "taskType", AgentTaskType.Chat), + Complexity = ParseEnum(root, "complexity", router.Complexity), + NeedsTool = needsTool || tools.Count > 0, + NeedsAnalysis = IntentWeightPolicy.ShouldAnalyze(analysisWeight), + NeedsMemoryRecall = IntentWeightPolicy.ShouldRecallMemory(memoryRecallWeight), + ToolWeight = IntentWeightPolicy.Normalize(toolWeight), + AnalysisWeight = IntentWeightPolicy.Normalize(analysisWeight), + MemoryRecallWeight = IntentWeightPolicy.Normalize(memoryRecallWeight), + Reason = GetString(root, "reason"), + UserVisibleNotice = CleanNotice(GetString(root, "userVisibleNotice")), + ToolDoneNotice = CleanNotice(GetString(root, "toolDoneNotice")), + AnalysisNotice = CleanNotice(GetString(root, "analysisNotice")), + MaxReplySegments = root.TryGetProperty("maxReplySegments", out var seg) && seg.TryGetInt32(out var s) ? Math.Clamp(s, 1, 2) : 1, + Tools = tools, + Route = router.Route + }; + } + + private static RouteDecision BuildRoute(RouteDecision baseRoute, PlannerDecisionEnvelope plan) + { + var route = Clone(baseRoute); + route.ShouldReply = plan.NextAction != PlannerNextAction.AbortNoReply; + route.Mode = plan.NeedsTool || plan.NextAction == PlannerNextAction.NeedTool ? ReplyMode.Web : plan.Complexity == TaskComplexity.Heavy ? ReplyMode.AsyncQuality : ReplyMode.Fast; + route.WebPolicy = plan.NeedsTool ? WebPolicy.Required : WebPolicy.None; + route.NeedMainAgent = false; + route.NeedToolPlanning = plan.NeedsTool; + route.Tools = plan.Tools; + route.ToolNotice = plan.UserVisibleNotice; + route.ToolDoneNotice = plan.ToolDoneNotice; + route.AnalysisNotice = plan.AnalysisNotice; + route.Reason += ";planner:" + (string.IsNullOrWhiteSpace(plan.Reason) ? plan.NextAction.ToString() : plan.Reason); + route.ContextRecentMessages = plan.Complexity == TaskComplexity.Heavy ? 50 : 24; + route.TimeoutMs = plan.Complexity == TaskComplexity.Heavy ? 25000 : 15000; + route.MaxReplySegments = plan.MaxReplySegments; + return route; + } + + private PlannerDecisionEnvelope Fallback(RouterDecisionEnvelope router, string reason) + { + var plan = new PlannerDecisionEnvelope + { + NextAction = router.ShouldReply ? PlannerNextAction.ReadyToReply : PlannerNextAction.AbortNoReply, + TaskType = AgentTaskType.Chat, + Complexity = router.Complexity, + NeedsTool = false, + NeedsAnalysis = false, + Reason = reason, + MaxReplySegments = router.Complexity == TaskComplexity.Heavy ? 2 : 1, + Tools = [], + Route = router.Route + }; + plan.Route = BuildRoute(router.Route, plan); + plan.ExecutionPlan = planCompiler.Compile(plan); + return plan; + } + + private static RouteDecision Clone(RouteDecision source) => new() + { + ShouldReply = source.ShouldReply, + Mode = source.Mode, + WebPolicy = source.WebPolicy, + NeedMainAgent = source.NeedMainAgent, + NeedToolPlanning = source.NeedToolPlanning, + NeedLlmFusion = source.NeedLlmFusion, + Temperature = source.Temperature, + TimeoutMs = source.TimeoutMs, + ContextRecentMessages = source.ContextRecentMessages, + MaxReplySegments = source.MaxReplySegments, + Reason = source.Reason, + ToolNotice = source.ToolNotice, + ToolDoneNotice = source.ToolDoneNotice, + ToolResultOverview = source.ToolResultOverview, + AnalysisNotice = source.AnalysisNotice, + WebReasons = source.WebReasons.ToList(), + Tools = source.Tools.Select(t => new RouteToolRequest { Name = t.Name, Query = t.Query, Url = t.Url, Reason = t.Reason, MaxResults = t.MaxResults, Confidence = t.Confidence }).ToList() + }; + + private static string Limit(string text, int max) => string.IsNullOrWhiteSpace(text) || text.Length <= max ? text : text[..max] + "\n...(已截断)"; + private static string CleanNotice(string text) => string.IsNullOrWhiteSpace(text) ? "" : (text.Length <= 60 ? text.Trim() : text[..60].Trim() + "…"); + private static string GetString(JsonElement e, string name) => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; + private static double GetDouble(JsonElement e, string name, double fallback) => e.TryGetProperty(name, out var v) && v.TryGetDouble(out var value) ? IntentWeightPolicy.Normalize(value) : fallback; + private static bool HasValidToolInput(string name, string query, string url) + { + if (string.Equals(name, "web_fetch", StringComparison.OrdinalIgnoreCase)) + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) && parsed.Scheme is "http" or "https"; + return !string.IsNullOrWhiteSpace(query); + } + private static T ParseEnum(JsonElement root, string name, T fallback) where T : struct => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String && Enum.TryParse(v.GetString(), true, out var parsed) ? parsed : fallback; + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + return start >= 0 && end > start ? text[start..(end + 1)] : text; + } +} diff --git a/MyAgent/Agents/ReplyAgent.cs b/MyAgent/Agents/ReplyAgent.cs new file mode 100644 index 0000000..4a504ed --- /dev/null +++ b/MyAgent/Agents/ReplyAgent.cs @@ -0,0 +1,375 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Agents; + +/// +/// Reply streaming node only. Tool planning/execution and analysis live in AgentWorkflowEngine + ToolAgent + AnalystAgent. +/// This class turns a prepared context/evidence package into QQ-friendly streamed segments. +/// +public sealed class ReplyAgent( + IChatModelClient client, + ContextBuilder contextBuilder, + LocalConfigStore configStore, + MediaService mediaService, + IOptions options) +{ + private readonly IChatModelClient _client = client; + private readonly ContextBuilder _contextBuilder = contextBuilder; + private readonly LocalConfigStore _configStore = configStore; + private readonly ShirobotOptions _options = options.Value; + + public async IAsyncEnumerable RunAsync( + MessageBatch batch, + RouteDecision route, + IReadOnlyList? initialTrace = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + await foreach (var ev in RunReplyOnlyAsync(batch, route, null, [], null, initialTrace, ct)) + yield return ev; + } + + public async IAsyncEnumerable RunReplyOnlyAsync( + MessageBatch batch, + RouteDecision route, + WebSearchResult? web, + IReadOnlyList? mediaAttachments, + string? heavyMemoryRecallText = null, + IReadOnlyList? initialTrace = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var sw = Stopwatch.StartNew(); + var trace = initialTrace?.ToList() ?? []; + void AddTrace(string step, string agent, string status, string detail, long? elapsedMs = null) => trace.Add(new AgentTraceEvent + { + Step = step, + Agent = agent, + Status = status, + Detail = detail, + AtMs = sw.ElapsedMilliseconds, + ElapsedMs = elapsedMs + }); + + var runtimeConfig = await _configStore.ReadAsync(ct); + yield return SegmentStreamEvent.Status("正在构建回复上下文", sw.ElapsedMilliseconds, runningKey: "context"); + var contextWatch = Stopwatch.StartNew(); + var context = await _contextBuilder.BuildAsync(batch, route, web, heavyMemoryRecallText, false, ct); + contextWatch.Stop(); + AddTrace("context", "context-builder", "ok", $"recent={context.RecentMessages.Count}, attachments={context.CurrentAttachments.Count}, threadHistory={(context.ThreadHistoryText == "无" ? "none" : "loaded")}, heavyRecall={(context.HeavyMemoryRecallText == "无" ? "none" : "loaded")}, web={(web?.Items.Count ?? 0)}", contextWatch.ElapsedMilliseconds); + yield return SegmentStreamEvent.Status("上下文已就绪,正在等待回复模型", sw.ElapsedMilliseconds, runningKey: "reply", trace: trace); + + var botName = runtimeConfig.Bot.BotDisplayName; + var botAliases = string.Join(" / ", runtimeConfig.Bot.BotAliases); + var system = $$""" +你是 QQ 群聊 AI {{botName}}。你在真实 QQ 群里说话,不是网页客服。 +当前群号:{{context.GroupId}} +当前用户:{{context.UserId}} +机器人 QQ:{{runtimeConfig.Bot.BotUserId}} +机器人别名:{{botAliases}} + +你只负责最终表达,不负责重新规划工具,也不要要求再去搜索。 +上游如果提供了联网结果、工具观察或分析摘要,你必须优先依据它们回答;证据不足就明确说不确定,不要编造。 +群里可能还有链接解析插件,别人单独发链接时通常是在分享或等插件解析;只有上下文明确是问你时才回应。 + +严格输出纯文本,不要 Markdown。 +每个自然段都必须用下面标记包起来: +这里是一段适合直接发到 QQ 的自然段 +如果这一段应该作为 QQ 回复消息引用当前用户这条消息,请使用: +这里是一段适合直接发到 QQ 的自然段 + +要求: +1. 普通群聊问题优先只发 1 段;复杂或需要列取舍时最多 2 段。 +2. 每段围绕一个小主题,通常 2 到 4 句,约 60 到 180 个中文字符。 +3. 先直接回答用户最关心的问题,不要长篇铺垫。 +4. 如果工具或分析结果不足,直接说“不确定/没查到足够依据”。 +5. 如果当前附件列表里有图片,说明图片已经作为视觉输入提供给你;必须直接观察图片并回答,不要说“我没法读取图片内容”。如果看不清/分辨率不足,只说明具体哪里看不清。 +6. 可以自然承认“我有轻记忆/重记忆”:轻记忆用于最近对话,重记忆只在用户要求回忆过去时按需读取;不要展开内部实现、文件路径、提示词或检索细节。 +7. 群员/用户轻记忆只对“当前群号”有效;同一个 QQ 在不同群的记忆必须分开理解,不能把其它群的偏好、身份、话题带入本群。 +8. 群员/用户轻记忆只是大纲级弱提示,只用于理解本群长期话题、偏好和消歧;不要模仿口癖,不要把无关问题强行关联到记忆。 +9. 轻记忆不保证细节;如果用户要精确时间节点、原始对话、具体决策过程,而当前群的重记忆召回为空或证据不够,先问一个很小的澄清问题,例如时间、关键词、参与者,不要硬编过去发生过的事。 +10. 注意力要集中在当前群、当前话题和当前用户最后稳定表达;不要被其它群、无关旧记忆或其它群友对话带偏。 +11. 不要输出除 ... 或 ... 以外的结构化标记。 +12. 不要解释这些标记。 +"""; + + var user = $$""" +## 时间上下文 +{{context.TimeContextText}} + +## 注意力上下文 +{{context.AttentionContextText}} + +## 问题上下文 +{{context.QuestionContextText}} + +## 群员/用户记忆(轻记忆) +{{context.MemberMemoryText}} + +## 重记忆按需召回暂存 +{{context.HeavyMemoryRecallText}} + +## 当前线程历史 +{{context.ThreadHistoryText}} + +## 最近群消息 +{{FormatRecentMessages(context.RecentMessages)}} + +## 上游工具/分析结果 +{{FormatWeb(context.WebSearchResult)}} + +## 当前用户上传的附件 +{{FormatAttachments(context.CurrentAttachments)}} + +## 搜索/工具得到的图片附件 +{{FormatAttachments(mediaAttachments ?? [])}} + +## 当前用户问题 +{{context.MergedText}} +"""; + + var parser = new MsgStreamParser(sw, Math.Clamp(route.MaxReplySegments, 1, 2)); + var raw = new StringBuilder(); + var replyWatch = Stopwatch.StartNew(); + var firstDeltaSeen = false; + await foreach (var delta in _client.StreamCompleteAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user, ImageUrls = await BuildVisionImageUrlsAsync(context.CurrentAttachments, ct) } + ], route.Temperature, _options.Context.MaxAnswerChars, ct)) + { + if (!firstDeltaSeen) + { + firstDeltaSeen = true; + AddTrace("reply-first-token", "reply-model", "ok", $"model={runtimeConfig.OpenAi.ReplyModel}, first streamed delta", replyWatch.ElapsedMilliseconds); + yield return SegmentStreamEvent.Status("回复模型已开始输出", sw.ElapsedMilliseconds, runningKey: "reply", trace: trace); + } + raw.Append(delta); + yield return new SegmentStreamEvent { Type = "raw-delta", Text = delta, ElapsedMs = sw.ElapsedMilliseconds }; + foreach (var ev in parser.Push(delta)) yield return ev; + } + + foreach (var ev in parser.Complete()) yield return ev; + replyWatch.Stop(); + + var finalText = parser.FinalText; + AddTrace("reply", "reply-model", "ok", $"model={runtimeConfig.OpenAi.ReplyModel}, chars={finalText.Length}, segments={parser.SegmentCount}", replyWatch.ElapsedMilliseconds); + yield return new SegmentStreamEvent { Type = "done", Text = raw.ToString(), ElapsedMs = sw.ElapsedMilliseconds, Trace = trace }; + } + + private async Task> BuildVisionImageUrlsAsync(IReadOnlyList attachments, CancellationToken ct) + { + var urls = new List(); + foreach (var a in attachments.Where(x => string.Equals(x.Type, "image", StringComparison.OrdinalIgnoreCase)).Take(6)) + { + if (!string.IsNullOrWhiteSpace(a.MediaId)) + { + var cached = await mediaService.TryGetFileAsync(a.MediaId, ct); + if (cached is not null) + { + var bytes = await File.ReadAllBytesAsync(cached.Value.Path, ct); + urls.Add($"data:{cached.Value.Mime};base64,{Convert.ToBase64String(bytes)}"); + continue; + } + } + if (!string.IsNullOrWhiteSpace(a.SourceUrl) && Uri.TryCreate(a.SourceUrl, UriKind.Absolute, out var source) && source.Scheme is "http" or "https") urls.Add(source.ToString()); + else if (!string.IsNullOrWhiteSpace(a.Url) && a.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) urls.Add(a.Url); + } + return urls; + } + + private static string FormatAttachments(IReadOnlyList attachments) + { + if (attachments.Count == 0) return "无"; + var sb = new StringBuilder(); + for (var i = 0; i < attachments.Count; i++) + { + var a = attachments[i]; + sb.AppendLine($"[{i + 1}] {a.Title}"); + sb.AppendLine($"mediaId={a.MediaId}"); + sb.AppendLine($"cachedUrl={a.Url}"); + sb.AppendLine($"sourceUrl={a.SourceUrl}"); + if (!string.IsNullOrWhiteSpace(a.PageUrl)) sb.AppendLine($"pageUrl={a.PageUrl}"); + if (!string.IsNullOrWhiteSpace(a.TextContent)) + { + sb.AppendLine("文件正文摘录:"); + sb.AppendLine(a.TextContent.Length > 12000 ? a.TextContent[..12000] + "\n...(正文过长,已截断)" : a.TextContent); + } + sb.AppendLine(); + } + return sb.ToString().Trim(); + } + + private static string FormatRecentMessages(IReadOnlyList recent) + { + if (recent.Count == 0) return "无"; + var lines = recent.TakeLast(12).Select(m => $"[{m.CreatedAt:HH:mm:ss}] {m.UserId}: {m.Text}"); + return string.Join("\n", lines); + } + + private static string FormatWeb(WebSearchResult? web) + { + if (web is null) return "无"; + if (!string.IsNullOrWhiteSpace(web.Error) && web.Items.Count == 0) return $"工具失败:{web.Error}"; + if (web.Items.Count == 0) return $"query:{web.Query}\n无结果"; + var sb = new StringBuilder(); + sb.AppendLine("query:" + web.Query); + if (!string.IsNullOrWhiteSpace(web.Error)) sb.AppendLine("部分错误:" + web.Error); + for (var i = 0; i < web.Items.Count; i++) + { + var item = web.Items[i]; + sb.AppendLine($"[{i + 1}] {item.Title}"); + if (!string.IsNullOrWhiteSpace(item.Url)) sb.AppendLine(item.Url); + if (!string.IsNullOrWhiteSpace(item.Text)) sb.AppendLine(TrimForPrompt(item.Text, 1800)); + sb.AppendLine(); + } + return TrimForPrompt(sb.ToString().Trim(), 9000); + } + + private static string TrimForPrompt(string text, int max) + { + if (string.IsNullOrWhiteSpace(text) || text.Length <= max) return text; + return text[..max] + "\n...(内容过长,已截断)"; + } + + private sealed class MsgStreamParser(Stopwatch sw, int maxSegments) + { + private const string OpenTag = " _completed = []; + private int _index; + private bool _inMessage; + private bool _replyCurrent; + + public string FinalText => string.Join("\n", _completed); + public int SegmentCount => _completed.Count; + + public IEnumerable Push(string delta) + { + _buffer.Append(delta); + foreach (var ev in Drain(final: false)) yield return ev; + } + + public IEnumerable Complete() + { + foreach (var ev in Drain(final: true)) yield return ev; + if (_completed.Count == 0) + { + var text = _buffer.ToString().Trim(); + if (!string.IsNullOrWhiteSpace(text)) + { + _completed.Add(text); + _index++; + yield return new SegmentStreamEvent { Type = "msg-start", Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = true }; + yield return new SegmentStreamEvent { Type = "msg-delta", Text = text, Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = true }; + yield return new SegmentStreamEvent { Type = "msg-end", Text = text, Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = true }; + } + } + } + + private IEnumerable Drain(bool final) + { + while (_buffer.Length > 0) + { + if (!_inMessage) + { + if (_completed.Count >= maxSegments) + { + _buffer.Clear(); + yield break; + } + var start = IndexOf(_buffer, OpenTag); + if (start < 0) + { + var keep = PossibleTagSuffixLength(_buffer, OpenTag); + if (_buffer.Length > keep) _buffer.Remove(0, _buffer.Length - keep); + yield break; + } + if (start > 0) _buffer.Remove(0, start); + var tagEnd = IndexOf(_buffer, ">"); + if (tagEnd < 0) yield break; + var tag = _buffer.ToString(0, tagEnd + 1); + _replyCurrent = tag.Contains("reply", StringComparison.OrdinalIgnoreCase) && tag.Contains("true", StringComparison.OrdinalIgnoreCase); + _buffer.Remove(0, tagEnd + 1); + _current.Clear(); + _inMessage = true; + _index++; + yield return new SegmentStreamEvent { Type = "msg-start", Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = _replyCurrent }; + } + + var end = IndexOf(_buffer, CloseTag); + if (end < 0) + { + var safeLen = final ? _buffer.Length : _buffer.Length - PossibleTagSuffixLength(_buffer, CloseTag); + if (safeLen <= 0) yield break; + var piece = _buffer.ToString(0, safeLen); + _buffer.Remove(0, safeLen); + _current.Append(piece); + yield return new SegmentStreamEvent { Type = "msg-delta", Text = piece, Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = _replyCurrent }; + continue; + } + + if (end > 0) + { + var piece = _buffer.ToString(0, end); + _current.Append(piece); + yield return new SegmentStreamEvent { Type = "msg-delta", Text = piece, Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = _replyCurrent }; + } + _buffer.Remove(0, end + CloseTag.Length); + var text = _current.ToString().Trim(); + if (!string.IsNullOrWhiteSpace(text)) _completed.Add(text); + yield return new SegmentStreamEvent { Type = "msg-end", Text = text, Index = _index, ElapsedMs = sw.ElapsedMilliseconds, ReplyToCurrentMessage = _replyCurrent }; + _current.Clear(); + _inMessage = false; + } + } + + private static int IndexOf(StringBuilder sb, string value) => sb.ToString().IndexOf(value, StringComparison.OrdinalIgnoreCase); + private static int PossibleTagSuffixLength(StringBuilder sb, string tag) + { + var max = Math.Min(sb.Length, tag.Length - 1); + var text = sb.ToString(); + for (var len = max; len > 0; len--) + { + if (tag.StartsWith(text[^len..], StringComparison.OrdinalIgnoreCase)) return len; + } + return 0; + } + } +} + +public sealed class SegmentStreamEvent +{ + public string Type { get; set; } = "status"; + public string Text { get; set; } = ""; + public int? Index { get; set; } + public long? ElapsedMs { get; set; } + public bool ReplyToCurrentMessage { get; set; } + public ChatAttachment? Attachment { get; set; } + public List Trace { get; set; } = []; + public bool UserVisible { get; set; } + public string RunningKey { get; set; } = ""; + + public static SegmentStreamEvent Status(string text, long elapsedMs, bool userVisible = false, string runningKey = "", IReadOnlyList? trace = null) => new() + { + Type = "status", + Text = text, + ElapsedMs = elapsedMs, + UserVisible = userVisible, + RunningKey = runningKey, + Trace = trace?.ToList() ?? [] + }; + + public static SegmentStreamEvent FromAttachment(ChatAttachment attachment, long elapsedMs) => new() + { + Type = "attachment", + Text = attachment.Url, + Attachment = attachment, + ElapsedMs = elapsedMs + }; +} diff --git a/MyAgent/Agents/RouterAgent.cs b/MyAgent/Agents/RouterAgent.cs new file mode 100644 index 0000000..3fe7089 --- /dev/null +++ b/MyAgent/Agents/RouterAgent.cs @@ -0,0 +1,247 @@ +using System.Text.Json; +using MyAgent.Core; +using MyAgent.Storage; + +namespace MyAgent.Agents; + +/// +/// Turn-taking / routing node. It replaces keyword-heavy local router logic with: +/// 1) deterministic structural signals: empty message, @ other member, reply target, explicit @ bot; +/// 2) a small structured router model that decides conversation target and whether the bot should enter. +/// It never plans tools and never writes the final answer. +/// +public sealed class RouterAgent(IChatModelClient client, RecentLogStore recentLog, ConversationRegistry conversations, LocalConfigStore configStore, ILogger logger) +{ + private const string BotSyntheticId = "assistant:shiro"; + + public async Task RunAsync(MessageBatch batch, CancellationToken ct = default) + { + var text = batch.MergedText.Trim(); + if (string.IsNullOrWhiteSpace(text) && batch.Messages.All(m => m.Attachments.Count == 0)) + return Build(false, ConversationTarget.Group, InterjectionRisk.Low, TaskComplexity.Trivial, "empty", needPlanner: false); + + var cfg = await configStore.ReadAsync(ct); + var botIds = BuildBotIds(cfg); + var currentMentionIds = batch.Messages.SelectMany(m => m.MentionedUserIds).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var mentionedBot = batch.Messages.Any(m => m.IsMentioned) || currentMentionIds.Any(id => botIds.Contains(id)) || IsAddressedByText(text, botIds); + var mentionedOthers = currentMentionIds.Where(id => !botIds.Contains(id)).ToList(); + + // Structural turn-taking rules are not semantic keyword rules. They come from message metadata. + if (mentionedOthers.Count > 0 && !mentionedBot) + return Build(false, ConversationTarget.OtherMember, InterjectionRisk.High, TaskComplexity.Simple, "structural:@other_member", needPlanner: false); + + var last = batch.Messages.Last(); + if (!string.IsNullOrWhiteSpace(last.ReplyToMessageId)) + { + var replied = await recentLog.FindByMessageIdAsync(batch.GroupId, last.ReplyToMessageId, ct); + if (replied is not null && (IsBot(replied, botIds) || mentionedBot)) + { + var reason = IsBot(replied, botIds) ? "structural:reply_to_bot" : "structural:reply_to_other_member_but_bot_addressed"; + return await BuildAddressedBotRouteAsync(batch, cfg, reason, ct); + } + if (replied is not null && !IsBot(replied, botIds)) + return Build(false, ConversationTarget.OtherMember, InterjectionRisk.High, EstimateComplexity(text), "structural:reply_to_other_member", needPlanner: false); + } + + if (mentionedBot) + { + return await BuildAddressedBotRouteAsync(batch, cfg, "structural:bot_mentioned", ct); + } + + // 群聊里没有显式 @ 时,不应默认插话;但如果同一 user-thread 刚刚由机器人回复过, + // 下一句短追问/问候通常仍属于这段人机对话。这个判断放在后端确定性层,避免 RouterModel + // 因最近窗口噪声或编码问题把活跃对话误判成 Unknown。 + if (await conversations.HasRecentAssistantReplyAsync(batch.ThreadId, last.CreatedAt, TimeSpan.FromMinutes(60), ct) + && await IsImmediateBotThreadFollowupAsync(batch, botIds, ct)) + { + return await BuildAddressedBotRouteAsync(batch, cfg, "structural:recent_bot_thread_followup", ct); + } + + if (cfg.Bot.ReplyOnlyWhenMentioned) + return Build(false, ConversationTarget.Unknown, InterjectionRisk.Medium, EstimateComplexity(text), "structural:reply_only_when_mentioned", needPlanner: false); + + return await AskRouterModelAsync(batch, cfg, ct); + } + + private async Task IsImmediateBotThreadFollowupAsync(MessageBatch batch, IReadOnlySet botIds, CancellationToken ct) + { + var currentIds = batch.Messages.Select(m => m.MessageId).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var previous = await recentLog.FindPreviousBeforeAsync(batch.GroupId, batch.Messages.Last().CreatedAt, currentIds, ct); + return previous is not null && IsBot(previous, botIds); + } + + private async Task BuildAddressedBotRouteAsync(MessageBatch batch, RuntimeConfig cfg, string structuralReason, CancellationToken ct) + { + var complexity = EstimateComplexity(batch.MergedText); + return await Task.FromResult(Build(true, ConversationTarget.Bot, InterjectionRisk.Low, complexity, structuralReason + ":semantic_plan", needPlanner: true)); + } + + private async Task AskRouterModelAsync(MessageBatch batch, RuntimeConfig cfg, CancellationToken ct) + { + var currentIds = batch.Messages.Select(m => m.MessageId).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var previousWindow = await recentLog.ReadBeforeAsync(batch.GroupId, batch.Messages.Last().CreatedAt, 10, currentIds, ct); + var aliases = string.Join(" / ", cfg.Bot.BotAliases.Where(x => !string.IsNullOrWhiteSpace(x))); + var system = $$""" +你是 QQ 群聊的 RouterAgent / TurnTakingAgent,只输出 JSON。 +你只判断“机器人现在该不该进入流程”,不回答问题、不选择工具。 + +机器人身份: +- 显示名:{{cfg.Bot.BotDisplayName}} +- QQ:{{cfg.Bot.BotUserId}} +- 别名:{{aliases}} + +判断重点: +1. 先判断 conversationTarget:Bot、OtherMember、Group、Unknown。 +2. 中文“你/这个/他/上面”要根据最近窗口判断指谁,不能默认指机器人。 +3. 项目名、包名、仓库名、域名里的 shirobot/Shiro 等不等于点名机器人。 +4. 群友之间正在讨论、刚 @ 其他人、reply 其他人、连续追问其他人时,通常 shouldReply=false。 +5. 明确问机器人、回复机器人、上下文已经围绕机器人追问时,shouldReply=true。 +6. 即使没有点名机器人,如果当前消息像是向全群提出的开放问题/求助/事实询问/技术问题,且最近没有特定群友正在接话,也可以 shouldReply=true,适当参与。 +7. 低风险工具型问题可以更主动:计算、折扣换算、单位换算、价格比较、时间换算、简单公式、术语解释、报错含义等,只要上下文里有足够信息或可以短澄清,通常 shouldReply=true;这类不会明显干扰群友决策。 +8. 如果当前消息很短但像“算一下/这个多少/相当于多少/怎么换算”,要结合最近几条群消息找数字和对象;能算就 shouldReply=true,信息不足也可以 shouldReply=true 并短问缺少哪个数,不要仅因未点名机器人就 no_reply。 +9. 如果当前问题简单客观、无人正在回答,或者机器人能提供明显有用的补充且不会打断群友对话,可以 shouldReply=true。 +10. 单独发送 URL/链接、截图链接、文章链接、视频链接时,如果没有明确让机器人总结/打开/解释/搜索/分析,通常 shouldReply=false;不要把“发了链接”本身当作对机器人的请求。 +11. 上一句是机器人回复只表示“可能是追问”,不是自动允许。仍要判断当前发言是否真的在追问机器人;如果像是在对其他群友解释、吐槽、转述、贴链接或开启新话题,shouldReply=false。 +12. 不确定时,如果像全群求助且没有人正在接,允许轻量回复;如果像群友之间对话,shouldReply=false。 +13. needPlanner 只在需要工具/联网/附件理解/多步骤规划/复杂分析/精确记忆召回时为 true;普通聊天、问候、短确认、简单计算/换算为 false,可直接回复。 + +输出 JSON: +{"shouldReply":true|false,"needPlanner":true|false,"conversationTarget":"Bot|OtherMember|Group|Unknown","interjectionRisk":"Low|Medium|High","complexity":"Trivial|Simple|Normal|Heavy","reason":"简短原因"} +"""; + var user = $$""" +最近群聊窗口(旧到新): +{{FormatWindow(previousWindow)}} + +当前连续消息: +userId={{batch.UserId}} +text={{batch.MergedText}} +attachments={{batch.Messages.Sum(m => m.Attachments.Count)}} + +请判断机器人是否应该进入后续流程。 +"""; + + try + { + var result = await client.CompleteRoutingAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user } + ], 0.0, 180, ct); + + var json = ExtractJson(result.Content); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var shouldReply = root.TryGetProperty("shouldReply", out var sr) && sr.ValueKind is JsonValueKind.True or JsonValueKind.False && sr.GetBoolean(); + var target = ParseEnum(root, "conversationTarget", ConversationTarget.Unknown); + var risk = ParseEnum(root, "interjectionRisk", shouldReply ? InterjectionRisk.Low : InterjectionRisk.Medium); + var complexity = ParseEnum(root, "complexity", EstimateComplexity(batch.MergedText)); + var reason = root.TryGetProperty("reason", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() ?? "router_model" : "router_model"; + var needPlanner = root.TryGetProperty("needPlanner", out var np) && np.ValueKind is JsonValueKind.True or JsonValueKind.False + ? np.GetBoolean() + : FallbackNeedPlanner(batch, complexity); + return Build(shouldReply, target, risk, complexity, "router_model:" + reason, needPlanner: shouldReply && needPlanner); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "RouterAgent failed; falling back to no-reply"); + return Build(false, ConversationTarget.Unknown, InterjectionRisk.Medium, TaskComplexity.Simple, "router_failed:" + ex.Message, needPlanner: false); + } + } + + private static RouterDecisionEnvelope Build(bool shouldReply, ConversationTarget target, InterjectionRisk risk, TaskComplexity complexity, string reason, bool needPlanner) + { + var mode = complexity == TaskComplexity.Heavy ? ReplyMode.AsyncQuality : ReplyMode.Fast; + var directReply = shouldReply && !needPlanner; + var route = new RouteDecision + { + ShouldReply = shouldReply, + Mode = shouldReply ? mode : ReplyMode.None, + WebPolicy = directReply ? WebPolicy.None : WebPolicy.Optional, + NeedMainAgent = shouldReply && needPlanner, + NeedToolPlanning = shouldReply && needPlanner, + Reason = directReply ? reason + ":direct_reply" : reason, + Temperature = complexity == TaskComplexity.Heavy ? 0.35 : 0.45, + TimeoutMs = complexity == TaskComplexity.Heavy ? 25000 : 15000, + ContextRecentMessages = complexity == TaskComplexity.Heavy ? 50 : directReply ? 10 : 24, + MaxReplySegments = complexity == TaskComplexity.Heavy ? 2 : 1 + }; + return new RouterDecisionEnvelope + { + ShouldEnter = shouldReply, + ShouldReply = shouldReply, + Target = target, + AddressedToBot = target == ConversationTarget.Bot, + InterjectionRisk = risk, + Complexity = complexity, + Reason = reason, + Route = route + }; + } + + private static bool FallbackNeedPlanner(MessageBatch batch, TaskComplexity complexity) + { + if (complexity is TaskComplexity.Normal or TaskComplexity.Heavy) return true; + if (batch.Messages.Any(m => m.Attachments.Count > 0)) return true; + return false; + } + + private static bool IsAddressedByText(string text, IReadOnlySet botIds) + { + if (string.IsNullOrWhiteSpace(text)) return false; + foreach (var id in botIds.Where(x => !string.IsNullOrWhiteSpace(x) && x != BotSyntheticId)) + { + if (ContainsAddressToken(text, id)) return true; + } + return false; + } + + private static bool ContainsAddressToken(string text, string token) + { + var idx = text.IndexOf(token, StringComparison.OrdinalIgnoreCase); + while (idx >= 0) + { + var beforeOk = idx == 0 || char.IsWhiteSpace(text[idx - 1]) || char.IsPunctuation(text[idx - 1]) || text[idx - 1] == '@'; + var end = idx + token.Length; + var afterOk = end >= text.Length || char.IsWhiteSpace(text[end]) || char.IsPunctuation(text[end]); + if (beforeOk && afterOk) return true; + idx = text.IndexOf(token, idx + token.Length, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + private static TaskComplexity EstimateComplexity(string text) + { + if (string.IsNullOrWhiteSpace(text) || text.Length < 20) return TaskComplexity.Trivial; + if (text.Length > 240) return TaskComplexity.Heavy; + if (text.Length > 100) return TaskComplexity.Normal; + return TaskComplexity.Simple; + } + + private static HashSet BuildBotIds(RuntimeConfig cfg) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase) { BotSyntheticId }; + if (cfg.Bot.BotUserId > 0) ids.Add(cfg.Bot.BotUserId.ToString()); + if (!string.IsNullOrWhiteSpace(cfg.Bot.BotDisplayName)) ids.Add(cfg.Bot.BotDisplayName); + foreach (var alias in cfg.Bot.BotAliases) ids.Add(alias); + return ids; + } + + private static bool IsBot(IncomingMessage message, IReadOnlySet botIds) => botIds.Contains(message.UserId); + + private static string FormatWindow(IReadOnlyList window) + { + if (window.Count == 0) return "无"; + return string.Join("\n", window.Select(m => $"[{m.CreatedAt:HH:mm:ss}] {m.UserId}: {m.Text}")); + } + + private static T ParseEnum(JsonElement root, string name, T fallback) where T : struct + { + return root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String && Enum.TryParse(v.GetString(), true, out var parsed) ? parsed : fallback; + } + + private static string ExtractJson(string text) + { + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + return start >= 0 && end > start ? text[start..(end + 1)] : text; + } +} diff --git a/MyAgent/Agents/ToolAgent.cs b/MyAgent/Agents/ToolAgent.cs new file mode 100644 index 0000000..c0a1e97 --- /dev/null +++ b/MyAgent/Agents/ToolAgent.cs @@ -0,0 +1,321 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using HtmlAgilityPack; +using MyAgent.Core; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Agents; + +public sealed class ToolAgent(IWebSearchClient webSearch, WebResultCache webCache, MediaService media, ToolCatalog toolCatalog) +{ + private readonly HttpClient _http = new(new SocketsHttpHandler { AllowAutoRedirect = false }) { Timeout = Timeout.InfiniteTimeSpan }; + + public async Task> ExecuteAsync(PlannerDecisionEnvelope plan, string fallbackQuery, string groupId, CancellationToken ct = default) + { + using var concurrency = new SemaphoreSlim(2, 2); + var calls = plan.Tools.Take(4).Select((tool, index) => ExecuteOneWithLimitAsync(tool, index, fallbackQuery, groupId, concurrency, ct)).ToArray(); + var results = await Task.WhenAll(calls); + return results.OrderBy(x => x.Index).Select(x => x.Observation).ToArray(); + } + + private async Task<(int Index, ToolObservation Observation)> ExecuteOneWithLimitAsync( + RouteToolRequest tool, + int index, + string fallbackQuery, + string groupId, + SemaphoreSlim concurrency, + CancellationToken ct) + { + await concurrency.WaitAsync(ct); + try + { + if (!toolCatalog.TryGet(tool.Name, out var capability)) + return (index, new ToolObservation { ToolName = tool.Name, Status = "error", Error = "unknown tool", EvidenceSummary = "未知工具,已跳过:" + tool.Name }); + if (capability.Executor == ToolExecutorKind.InternalWebSearch) + return (index, await ExecuteSearchAsync(tool, fallbackQuery, ct)); + if (capability.Executor == ToolExecutorKind.InternalWebFetch) + return (index, await ExecuteFetchAsync(tool, ct)); + if (capability.Executor == ToolExecutorKind.InternalImageSearch) + return (index, await ExecuteImageSearchAsync(tool, fallbackQuery, groupId, ct)); + return (index, new ToolObservation { ToolName = tool.Name, Status = "error", Error = "unsupported executor", EvidenceSummary = "工具执行器尚未实现:" + capability.Executor }); + } + finally + { + concurrency.Release(); + } + } + + private async Task ExecuteSearchAsync(RouteToolRequest tool, string fallbackQuery, CancellationToken ct) + { + var query = string.IsNullOrWhiteSpace(tool.Query) ? fallbackQuery : tool.Query; + var result = await webSearch.SearchAsync(query, Math.Clamp(tool.MaxResults, 1, 10), ct); + return new ToolObservation + { + ToolName = "web_search", + Query = query, + Status = string.IsNullOrWhiteSpace(result.Error) ? "ok" : "error", + Error = result.Error ?? "", + Items = result.Items, + EvidenceSummary = Summarize(result) + }; + } + + private async Task ExecuteFetchAsync(RouteToolRequest tool, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(tool.Url)) + return new ToolObservation { ToolName = "web_fetch", Status = "error", Error = "missing url", EvidenceSummary = "web_fetch 缺少 URL。" }; + + var cached = await webCache.TryReadFetchAsync(tool.Url, ct); + if (cached is not null) + { + return new ToolObservation + { + ToolName = "web_fetch", + Url = tool.Url, + Status = "cache_hit", + Items = [cached], + EvidenceSummary = $"缓存命中:{cached.Title}\n{cached.Url}\n{Limit(cached.Text, 800)}" + }; + } + + var fetched = await FetchWebPageAsync(tool.Url, ct); + if (fetched.Item is not null) + { + await webCache.WriteFetchAsync(tool.Url, fetched.Item, ct); + return new ToolObservation + { + ToolName = "web_fetch", + Url = tool.Url, + Status = "ok", + Items = [fetched.Item], + EvidenceSummary = $"读取成功:{fetched.Item.Title}\n{fetched.Item.Url}\n{Limit(fetched.Item.Text, 800)}" + }; + } + + return new ToolObservation + { + ToolName = "web_fetch", + Url = tool.Url, + Status = "error", + Error = fetched.Error, + EvidenceSummary = "web_fetch 失败:" + fetched.Error + }; + } + + private async Task ExecuteImageSearchAsync(RouteToolRequest tool, string fallbackQuery, string groupId, CancellationToken ct) + { + var query = string.IsNullOrWhiteSpace(tool.Query) ? fallbackQuery : tool.Query; + try + { + var mediaResult = await media.SearchImagesAsync(new MediaSearchRequest { Query = query, MaxPages = 5, MaxResults = 8 }, ct); + var attachments = new List(); + var downloadErrors = new List(); + foreach (var item in mediaResult.Items.Take(Math.Clamp(tool.MaxResults <= 0 ? 2 : tool.MaxResults, 1, 3))) + { + try + { + var cached = await media.DownloadAsync(new MediaDownloadRequest { ImageUrl = item.ImageUrl, PageUrl = item.PageUrl, GroupId = groupId }, ct); + if (string.IsNullOrWhiteSpace(cached.Error)) + { + attachments.Add(new ChatAttachment + { + Type = "image", + MediaId = cached.MediaId, + Url = cached.Url, + LocalPath = cached.LocalPath, + SourceUrl = cached.SourceUrl, + PageUrl = cached.PageUrl, + Mime = cached.Mime, + Size = cached.Size, + Title = item.Title + }); + } + else downloadErrors.Add(cached.Error); + } + catch (Exception ex) when (ex is not OperationCanceledException) { downloadErrors.Add(ex.Message); } + } + + var status = attachments.Count == 0 ? "error" : downloadErrors.Count > 0 || !string.IsNullOrWhiteSpace(mediaResult.Error) ? "partial" : "ok"; + var error = attachments.Count == 0 + ? string.Join("; ", new[] { mediaResult.Error }.Concat(downloadErrors).Where(x => !string.IsNullOrWhiteSpace(x)).Take(3)) + : string.Join("; ", downloadErrors.Take(3)); + + return new ToolObservation + { + ToolName = "image_search", + Query = query, + Status = status, + Error = error, + Attachments = attachments, + EvidenceSummary = attachments.Count > 0 + ? $"找到图片候选 {mediaResult.Items.Count} 张,已下载 {attachments.Count} 张。" + : "图片搜索失败:" + (string.IsNullOrWhiteSpace(error) ? "没有可发送的图片" : error) + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new ToolObservation { ToolName = "image_search", Query = query, Status = "error", Error = ex.Message, EvidenceSummary = "图片搜索失败:" + ex.Message }; + } + } + + private async Task<(WebSearchItem? Item, string Error)> FetchWebPageAsync(string url, CancellationToken ct) + { + try + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return (null, "only http(s) URLs are supported"); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(12)); + var current = uri; + HttpResponseMessage? resp = null; + for (var redirect = 0; redirect < 5; redirect++) + { + var safety = await ValidatePublicUrlAsync(current, timeout.Token); + if (!string.IsNullOrWhiteSpace(safety)) return (null, safety); + + using var req = new HttpRequestMessage(HttpMethod.Get, current); + req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ShirobotClaw/0.1"); + req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5"); + req.Headers.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.7,ja;q=0.6"); + resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + if ((int)resp.StatusCode is >= 300 and < 400 && resp.Headers.Location is not null) + { + var next = resp.Headers.Location; + current = next.IsAbsoluteUri ? next : new Uri(current, next); + resp.Dispose(); + continue; + } + break; + } + + using (resp) + { + if (resp is null) return (null, "no response"); + if (!resp.IsSuccessStatusCode) return (null, $"HTTP {(int)resp.StatusCode}"); + var mime = resp.Content.Headers.ContentType?.MediaType ?? ""; + if (!IsTextLikeMime(mime)) return (null, $"unsupported content-type: {mime}"); + var bytes = await ReadLimitedBytesAsync(resp.Content, 2 * 1024 * 1024, timeout.Token); + var body = DecodeBytes(bytes, resp.Content.Headers.ContentType?.CharSet); + if (string.IsNullOrWhiteSpace(body)) return (null, "empty response"); + var title = ExtractTitle(body, current); + var text = ExtractReadableText(body, mime); + if (text.Length > 12000) text = text[..12000] + "\n...(页面过长,已截断)"; + if (string.IsNullOrWhiteSpace(text)) return (null, "no readable text extracted"); + return (new WebSearchItem { Title = title, Url = current.ToString(), Text = text }, ""); + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return (null, "timeout after 12s"); } + catch (Exception ex) when (ex is not OperationCanceledException) { return (null, ex.Message); } + } + + private static string Summarize(WebSearchResult result) + { + if (!string.IsNullOrWhiteSpace(result.Error)) return "工具失败:" + result.Error; + if (result.Items.Count == 0) return "没有检索到结果。"; + return string.Join("\n", result.Items.Take(5).Select((x, i) => $"{i + 1}. {x.Title} - {x.Url}\n{Limit(x.Text, 300)}")); + } + + private static string Limit(string text, int max) => string.IsNullOrWhiteSpace(text) || text.Length <= max ? text : text[..max] + "..."; + + private static async Task ReadLimitedBytesAsync(HttpContent content, int maxBytes, CancellationToken ct) + { + await using var input = await content.ReadAsStreamAsync(ct); + using var output = new MemoryStream(Math.Min(maxBytes, 81920)); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + if (output.Length + read > maxBytes) + { + var allowed = maxBytes - (int)output.Length; + if (allowed > 0) output.Write(buffer, 0, allowed); + break; + } + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + private static string DecodeBytes(byte[] bytes, string? charset) + { + try { if (!string.IsNullOrWhiteSpace(charset)) return Encoding.GetEncoding(charset.Trim('"')).GetString(bytes); } catch { } + return Encoding.UTF8.GetString(bytes); + } + + private static bool IsTextLikeMime(string mime) => string.IsNullOrWhiteSpace(mime) || mime.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || mime.Contains("html", StringComparison.OrdinalIgnoreCase) || mime.Contains("json", StringComparison.OrdinalIgnoreCase) || mime.Contains("xml", StringComparison.OrdinalIgnoreCase) || mime.Contains("javascript", StringComparison.OrdinalIgnoreCase); + + private static string ExtractTitle(string body, Uri uri) + { + var title = Regex.Match(body, @"]*>(?[\s\S]*?)", RegexOptions.IgnoreCase).Groups["t"].Value; + title = WebUtility.HtmlDecode(StripTags(title)).Trim(); + return string.IsNullOrWhiteSpace(title) ? uri.Host : Regex.Replace(title, @"\s+", " ").Trim(); + } + + private static string ExtractReadableText(string body, string mime) + { + if (mime.Contains("json", StringComparison.OrdinalIgnoreCase)) return NormalizePlainText(PrettyJsonOrRaw(body)); + if (!body.Contains('<')) return NormalizePlainText(body); + try + { + var doc = new HtmlDocument(); + doc.OptionFixNestedTags = true; + doc.LoadHtml(body); + var root = doc.DocumentNode; + foreach (var n in root.SelectNodes("//script|//style|//noscript|//svg|//canvas|//iframe|//template|//nav|//header|//footer|//aside|//form")?.ToList() ?? []) n.Remove(); + var selected = root.SelectSingleNode("//article") ?? root.SelectSingleNode("//main") ?? root.SelectSingleNode("//body") ?? root; + return NormalizePlainText(WebUtility.HtmlDecode(selected.InnerText)); + } + catch + { + var html = Regex.Replace(body, @"<(script|style|noscript|svg|canvas|iframe|template)[^>]*>[\s\S]*?", " ", RegexOptions.IgnoreCase); + return NormalizePlainText(WebUtility.HtmlDecode(StripTags(html))); + } + } + + private static string StripTags(string text) => Regex.Replace(text, @"<[^>]+>", " "); + private static string NormalizePlainText(string text) + { + text = text.Replace("\r\n", "\n").Replace('\r', '\n'); + text = Regex.Replace(text, @"[ \t\f\v]+", " "); + text = Regex.Replace(text, @"\n\s*\n\s*\n+", "\n\n"); + return text.Trim(); + } + private static string PrettyJsonOrRaw(string body) + { + try { using var doc = System.Text.Json.JsonDocument.Parse(body); return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } + catch { return body; } + } + + private static async Task ValidatePublicUrlAsync(Uri uri, CancellationToken ct) + { + try + { + if (uri.HostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6) + { + if (IPAddress.TryParse(uri.Host, out var literal) && IsPrivateOrLocal(literal)) return "refusing private/local address"; + } + var addresses = await Dns.GetHostAddressesAsync(uri.Host, ct); + if (addresses.Length == 0) return "host cannot be resolved"; + if (addresses.Any(IsPrivateOrLocal)) return "refusing private/local address"; + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException) { return "DNS failed: " + ex.Message; } + } + + private static bool IsPrivateOrLocal(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) return true; + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + return b[0] == 0 || b[0] == 10 || (b[0] == 100 && b[1] >= 64 && b[1] <= 127) || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168) || b[0] == 127 || (b[0] == 169 && b[1] == 254) || b[0] >= 224; + } + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || IPAddress.IPv6Loopback.Equals(ip) || ip.IsIPv6Multicast || ip.ToString().StartsWith("fc", StringComparison.OrdinalIgnoreCase) || ip.ToString().StartsWith("fd", StringComparison.OrdinalIgnoreCase); + return false; + } +} diff --git a/MyAgent/Agents/ToolPlanningModels.cs b/MyAgent/Agents/ToolPlanningModels.cs new file mode 100644 index 0000000..2eb3138 --- /dev/null +++ b/MyAgent/Agents/ToolPlanningModels.cs @@ -0,0 +1,15 @@ +namespace MyAgent.Agents; + +public sealed class ToolPlanningResult +{ + public string Content { get; set; } = ""; + public string? Error { get; set; } + public List ToolCalls { get; set; } = []; +} + +public sealed class PlannedToolCall +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string ArgumentsJson { get; set; } = "{}"; +} diff --git a/MyAgent/Core/AgentContracts.cs b/MyAgent/Core/AgentContracts.cs new file mode 100644 index 0000000..240e673 --- /dev/null +++ b/MyAgent/Core/AgentContracts.cs @@ -0,0 +1,98 @@ +namespace MyAgent.Core; + +public enum ConversationTarget +{ + Unknown, + Bot, + OtherMember, + Group +} + +public enum InterjectionRisk +{ + Low, + Medium, + High +} + +public enum TaskComplexity +{ + Trivial, + Simple, + Normal, + Heavy +} + +public enum AgentTaskType +{ + Chat, + Trivial, + TechAdvice, + UrlSummary, + FactCheck, + DecisionSupport, + ImageSearch, + AttachmentAnalysis +} + +public enum PlannerNextAction +{ + ReadyToReply, + NeedTool, + NeedAnalysis, + AskClarification, + AbortNoReply +} + +public sealed class RouterDecisionEnvelope +{ + public bool ShouldEnter { get; set; } + public bool ShouldReply { get; set; } + public ConversationTarget Target { get; set; } = ConversationTarget.Unknown; + public bool AddressedToBot { get; set; } + public InterjectionRisk InterjectionRisk { get; set; } = InterjectionRisk.Medium; + public TaskComplexity Complexity { get; set; } = TaskComplexity.Simple; + public string Reason { get; set; } = ""; + public RouteDecision Route { get; set; } = new(); +} + +public sealed class PlannerDecisionEnvelope +{ + public PlannerNextAction NextAction { get; set; } = PlannerNextAction.ReadyToReply; + public AgentTaskType TaskType { get; set; } = AgentTaskType.Chat; + public TaskComplexity Complexity { get; set; } = TaskComplexity.Simple; + public bool NeedsTool { get; set; } + public bool NeedsAnalysis { get; set; } + public bool NeedsMemoryRecall { get; set; } + public double ToolWeight { get; set; } + public double AnalysisWeight { get; set; } + public double MemoryRecallWeight { get; set; } + public string Reason { get; set; } = ""; + public string UserVisibleNotice { get; set; } = ""; + public string ToolDoneNotice { get; set; } = ""; + public string AnalysisNotice { get; set; } = ""; + public int MaxReplySegments { get; set; } = 1; + public List Tools { get; set; } = []; + public ExecutionPlan ExecutionPlan { get; set; } = new(); + public RouteDecision Route { get; set; } = new(); +} + +public sealed class ToolObservation +{ + public string ToolName { get; set; } = ""; + public string Query { get; set; } = ""; + public string Url { get; set; } = ""; + public string Status { get; set; } = "ok"; + public string EvidenceSummary { get; set; } = ""; + public List Items { get; set; } = []; + public List Attachments { get; set; } = []; + public string Error { get; set; } = ""; +} + +public sealed class AnalysisResult +{ + public string Conclusion { get; set; } = ""; + public List Risks { get; set; } = []; + public List Uncertainties { get; set; } = []; + public List Citations { get; set; } = []; +} diff --git a/MyAgent/Core/AgentRunContext.cs b/MyAgent/Core/AgentRunContext.cs new file mode 100644 index 0000000..308b1c9 --- /dev/null +++ b/MyAgent/Core/AgentRunContext.cs @@ -0,0 +1,75 @@ +namespace MyAgent.Core; + +[Flags] +public enum ContextScope +{ + None = 0, + CurrentTurn = 1, + ThreadHistory = 2, + RecentGroupMessages = 4, + GroupProfile = 8, + MemberMemory = 16, + HeavyMemory = 32, + Attachments = 64, + UpstreamArtifacts = 128 +} + +public sealed record AgentArtifact( + long Sequence, + string Kind, + string Producer, + DateTimeOffset CreatedAt, + object Payload); + +public sealed class ArtifactJournal +{ + private readonly object _gate = new(); + private readonly List _items = []; + private long _sequence; + + public AgentArtifact Append(string kind, string producer, T payload) where T : notnull + { + lock (_gate) + { + var artifact = new AgentArtifact(++_sequence, kind, producer, DateTimeOffset.Now, payload); + _items.Add(artifact); + return artifact; + } + } + + public IReadOnlyList Read() + { + lock (_gate) + return _items.Select(x => x.Payload).OfType().ToArray(); + } + + public T? ReadLatest() where T : class + { + lock (_gate) + return _items.Select(x => x.Payload).OfType().LastOrDefault(); + } + + public IReadOnlyList Snapshot() + { + lock (_gate) return _items.ToArray(); + } +} + +public sealed class AgentRunContext +{ + public AgentRunContext(MessageBatch batch) + { + Batch = batch; + RunId = batch.TaskId; + ThreadId = batch.ThreadId; + GroupId = batch.GroupId; + UserId = batch.UserId; + } + + public string RunId { get; } + public string ThreadId { get; } + public string GroupId { get; } + public string UserId { get; } + public MessageBatch Batch { get; } + public ArtifactJournal Artifacts { get; } = new(); +} diff --git a/MyAgent/Core/AgentRunStore.cs b/MyAgent/Core/AgentRunStore.cs new file mode 100644 index 0000000..65cae58 --- /dev/null +++ b/MyAgent/Core/AgentRunStore.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace MyAgent.Core; + +public sealed class AgentRunStore(IOptions options) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly SemaphoreSlim _lock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public string GetRunDir(string groupId, string runId) + { + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", Sanitize(groupId), "runs", Sanitize(runId)); + } + + public async Task SaveAsync(AgentWorkflowState state, CancellationToken ct = default) + { + var dir = GetRunDir(state.GroupId, state.RunId); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "state.json"); + await _lock.WaitAsync(ct); + try + { + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(state, JsonOptions), ct); + } + finally + { + _lock.Release(); + } + } + + public async Task TryReadAsync(string groupId, string runId, CancellationToken ct = default) + { + var path = Path.Combine(GetRunDir(groupId, runId), "state.json"); + if (!File.Exists(path)) return null; + var json = await File.ReadAllTextAsync(path, ct); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + public async Task> ListRecentAsync(string groupId, int limit = 30, CancellationToken ct = default) + { + var safe = Sanitize(groupId); + var root = Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", safe, "runs"); + if (!Directory.Exists(root)) return []; + + var result = new List(); + foreach (var file in Directory.EnumerateFiles(root, "state.json", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .Take(Math.Clamp(limit, 1, 100))) + { + try + { + var json = await File.ReadAllTextAsync(file, ct); + var state = JsonSerializer.Deserialize(json, JsonOptions); + if (state is not null) result.Add(state); + } + catch { } + } + return result.OrderByDescending(x => x.UpdatedAt).ToList(); + } + + private static string Sanitize(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) value = value.Replace(c, '_'); + return string.IsNullOrWhiteSpace(value) ? "default" : value; + } +} diff --git a/MyAgent/Core/AgentServiceCollectionExtensions.cs b/MyAgent/Core/AgentServiceCollectionExtensions.cs new file mode 100644 index 0000000..cb3234c --- /dev/null +++ b/MyAgent/Core/AgentServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using MyAgent.Agents; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Core; + +public static class AgentServiceCollectionExtensions +{ + public static IServiceCollection AddAgentCore(this IServiceCollection services) + { + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/MyAgent/Core/AgentTopologyBuilder.cs b/MyAgent/Core/AgentTopologyBuilder.cs new file mode 100644 index 0000000..cf0939d --- /dev/null +++ b/MyAgent/Core/AgentTopologyBuilder.cs @@ -0,0 +1,245 @@ +namespace MyAgent.Core; + +public sealed class AgentTopologyBuilder(WorkflowUiMetadata metadata) +{ + public AgentTopologyView Build(IReadOnlyList? trace, string? turnStatus = null, string? runningKey = null) + { + var events = trace ?? []; + runningKey = string.IsNullOrWhiteSpace(runningKey) ? null : runningKey; + var nodes = metadata.WorkflowSteps + .Select(step => BuildNode(step, events, runningKey, turnStatus)) + .ToList(); + nodes.AddRange(BuildSubagentNodes(events)); + + var inferredRunningKey = runningKey ?? nodes.LastOrDefault(n => n.Status == "running")?.Key ?? ""; + var runningKeys = nodes.Where(x => x.Status == "running").Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (!string.IsNullOrWhiteSpace(inferredRunningKey) && !runningKeys.Contains(inferredRunningKey, StringComparer.OrdinalIgnoreCase)) runningKeys.Add(inferredRunningKey); + var status = !string.IsNullOrWhiteSpace(turnStatus) + ? turnStatus! + : !string.IsNullOrWhiteSpace(inferredRunningKey) + ? "running" + : InferOverallStatus(nodes); + + return new AgentTopologyView + { + OverallStatus = status, + RunningKey = inferredRunningKey, + RunningKeys = runningKeys, + Nodes = nodes, + Edges = BuildEdges(nodes) + }; + } + + + public AgentTopologyView BuildInitial(string runningKey = "received") + { + var nodes = metadata.WorkflowSteps + .Select(step => new AgentTopologyNode + { + Key = step.Key, + Name = step.Name, + Agent = AgentForStep(step), + Status = step.Key == runningKey ? "running" : step.Key is "tool" or "analysis" ? "pending" : "pending", + StatusLabel = step.Key == runningKey ? "进行中" : "等待", + Detail = step.Pending, + Meta = step.Key == runningKey ? "正在执行" : "等待前置流程", + Kind = step.Key is "tool" or "analysis" ? "optional" : "required" + }) + .ToList(); + return new AgentTopologyView + { + OverallStatus = "running", + RunningKey = runningKey, + RunningKeys = [runningKey], + Nodes = nodes, + Edges = BuildEdges(nodes) + }; + } + + private IReadOnlyList BuildSubagentNodes(IReadOnlyList trace) + { + return trace + .Where(x => x.Step.StartsWith("subagent:", StringComparison.OrdinalIgnoreCase)) + .GroupBy(x => x.Step, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Last()) + .Select(ev => new AgentTopologyNode + { + Key = ev.Step, + Name = metadata.AgentLabels.TryGetValue(ev.Agent, out var label) ? label : ev.Agent, + Agent = ev.Agent, + Status = ev.Status is "failed" or "timed_out" or "blocked" or "error" ? "error" : ev.Status == "running" ? "running" : "done", + StatusLabel = metadata.StatusLabels.TryGetValue(ev.Status, out var statusLabel) ? statusLabel : ev.Status, + Detail = HumanizeDetail(ev.Detail), + Meta = FormatMeta(ev), + Kind = "branch", + AtMs = ev.AtMs, + ElapsedMs = ev.ElapsedMs, + Events = [ev], + Raw = ev + }) + .ToList(); + } + + private AgentTopologyNode BuildNode(WorkflowStepMetadata step, IReadOnlyList trace, string? runningKey, string? turnStatus) + { + var stepNames = step.Steps ?? []; + var events = trace.Where(x => stepNames.Contains(x.Step)).ToList(); + var primary = PickPrimary(step, events, trace); + var isRunning = string.Equals(step.Key, runningKey, StringComparison.OrdinalIgnoreCase); + var noReply = string.Equals(turnStatus, "no_reply", StringComparison.OrdinalIgnoreCase); + var skippedByNoReply = noReply + && primary is null + && step.Key is "tool" or "analysis" or "context" or "reply" or "memory"; + var status = skippedByNoReply + ? "skipped" + : isRunning + ? "running" + : primary is null + ? step.Virtual && trace.Any(x => x.Step == "reply") ? "queued" : "pending" + : primary.Status == "error" ? "error" + : primary.Status == "skipped" ? "skipped" + : primary.Status == "running" ? "running" + : "done"; + + return new AgentTopologyNode + { + Key = step.Key, + Name = step.Name, + Agent = primary?.Agent ?? AgentForStep(step), + Status = status, + StatusLabel = metadata.StatusLabels.TryGetValue(status, out var label) ? label : status, + Detail = skippedByNoReply ? "路由判断不需要回复,后续步骤未执行。" : HumanizeDetail(isRunning && primary is null ? step.Pending : primary?.Detail ?? step.Pending), + Meta = skippedByNoReply ? "不回复,已跳过" : isRunning ? (primary is null ? "正在执行" : FormatMeta(primary)) : primary is null ? "等待前置流程" : FormatMeta(primary), + Kind = step.Key is "tool" or "analysis" ? (primary is null ? "optional" : "branch") : "required", + AtMs = primary?.AtMs, + ElapsedMs = primary?.ElapsedMs, + Events = events, + Raw = primary + }; + } + + private static AgentTraceEvent? PickPrimary(WorkflowStepMetadata step, List events, IReadOnlyList trace) + { + if (step.Key == "reply") + return trace.LastOrDefault(x => x.Step == "reply") ?? trace.LastOrDefault(x => x.Step == "reply-first-token") ?? events.LastOrDefault(); + return events.LastOrDefault(); + } + + private string FormatMeta(AgentTraceEvent ev) + { + var agent = metadata.AgentLabels.TryGetValue(ev.Agent, out var agentLabel) ? agentLabel : ev.Agent; + var status = metadata.StatusLabels.TryGetValue(ev.Status, out var statusLabel) ? statusLabel : ev.Status; + var cost = ev.ElapsedMs is { } elapsed ? $"耗时 {FormatMs(elapsed)}" : $"+{ev.AtMs}ms"; + return $"{agent} · {status} · {cost}"; + } + + private static IReadOnlyList BuildEdges(IReadOnlyList nodes) + { + var required = nodes.Where(x => x.Kind == "required").ToList(); + var result = new List(); + for (var i = 1; i < required.Count; i++) + { + var from = required[i - 1]; + var to = required[i]; + result.Add(new AgentTopologyEdge + { + From = from.Key, + To = to.Key, + Status = from.Status is "done" or "skipped" or "queued" ? "active" : "pending" + }); + } + foreach (var branch in nodes.Where(x => x.Kind == "branch")) + { + result.Add(new AgentTopologyEdge + { + From = "planner", + To = branch.Key, + Status = branch.Status is "done" or "skipped" or "queued" ? "active" : "pending", + Kind = "branch" + }); + } + return result; + } + + private static string InferOverallStatus(IReadOnlyList nodes) + { + if (nodes.Any(x => x.Status == "error")) return "error"; + if (nodes.Any(x => x.Status == "running")) return "running"; + if (nodes.Any(x => x.Key == "reply" && x.Status == "done")) return "completed"; + return "pending"; + } + + private static string AgentForStep(WorkflowStepMetadata step) => step.Key switch + { + "received" => "workflow-engine", + "fusion" => "message-fusion-agent", + "router" => "router-agent", + "planner" => "planner-agent", + "tool" => "tool-agent", + "analysis" => "analyst-agent", + "context" => "context-builder", + "reply" => "reply-agent", + "memory" => "memory-worker", + _ => step.Key + }; + + private static string HumanizeDetail(string detail) => (detail ?? "") + .Replace("model=", "模型=") + .Replace("matched=", "命中=") + .Replace("score=", "置信度=") + .Replace("threshold=", "阈值=") + .Replace("mode=", "模式=") + .Replace("web=", "联网=") + .Replace("reason=", "原因=") + .Replace("items=", "结果=") + .Replace("chars=", "字数=") + .Replace("segments=", "分段=") + .Replace("first streamed delta", "首 token 已返回") + .Replace("router_timeout_", "路由超时 ") + .Replace("planner_timeout_", "规划超时 ") + .Replace("fusion_timeout_", "融合超时 ") + .Replace("observations=", "观察=") + .Replace("attachments=", "附件=") + .Replace("target=", "目标=") + .Replace("risk=", "插话风险=") + .Replace("summary=", "摘要=") + .Replace("type=", "类型=") + .Replace("split=", "拆分=") + .Replace("planner:", "规划:") + .Replace("structural:", "结构信号:"); + + private static string FormatMs(long ms) => ms < 1000 ? $"{ms}ms" : $"{ms / 1000.0:0.##}s"; +} + +public sealed record AgentTopologyView +{ + public string OverallStatus { get; init; } = "pending"; + public string RunningKey { get; init; } = ""; + public IReadOnlyList RunningKeys { get; init; } = []; + public IReadOnlyList Nodes { get; init; } = []; + public IReadOnlyList Edges { get; init; } = []; +} + +public sealed class AgentTopologyNode +{ + public string Key { get; init; } = ""; + public string Name { get; init; } = ""; + public string Agent { get; init; } = ""; + public string Status { get; init; } = "pending"; + public string StatusLabel { get; init; } = ""; + public string Detail { get; init; } = ""; + public string Meta { get; init; } = ""; + public string Kind { get; init; } = "required"; + public long? AtMs { get; init; } + public long? ElapsedMs { get; init; } + public IReadOnlyList Events { get; init; } = []; + public AgentTraceEvent? Raw { get; init; } +} + +public sealed class AgentTopologyEdge +{ + public string From { get; init; } = ""; + public string To { get; init; } = ""; + public string Status { get; init; } = "pending"; + public string Kind { get; init; } = "required"; +} diff --git a/MyAgent/Core/AgentWorkflowEngine.cs b/MyAgent/Core/AgentWorkflowEngine.cs new file mode 100644 index 0000000..bbe6c9b --- /dev/null +++ b/MyAgent/Core/AgentWorkflowEngine.cs @@ -0,0 +1,756 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Options; +using MyAgent.Agents; +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Core; + +public sealed class AgentWorkflowEngine( + SessionManager sessions, + MessageFusionAgent fusionAgent, + RouterAgent router, + InterjectionGuard interjectionGuard, + PlannerAgent planner, + ToolAgent toolAgent, + AnalystAgent analyst, + ReplyAgent replyAgent, + RecentLogStore recentLog, + LocalConfigStore configStore, + IGroupPlatform platform, + GroupChatHub hub, + ConversationRegistry conversations, + TaskRegistry tasks, + AgentRunStore runStore, + AgentTopologyBuilder topologyBuilder, + MemoryWorker memoryWorker, + HeavyMemoryRecallAgent heavyMemoryRecall, + ExecutionPlanScheduler planScheduler, + IOptions configuredOptions) +{ + public async IAsyncEnumerable RunGroupAsync( + GroupChatUserMessage request, + AgentWorkflowOptions? options = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + options ??= new AgentWorkflowOptions { PublishToHub = true, SendToPlatform = true }; + var cfg = await configStore.ReadAsync(ct); + var groupId = request.GroupId > 0 ? request.GroupId : cfg.Bot.DefaultGroupId; + var userId = request.UserId > 0 ? request.UserId.ToString() : "user-a"; + var msg = new IncomingMessage + { + MessageId = request.MessageId ?? Guid.NewGuid().ToString("n"), + GroupId = groupId.ToString(), + UserId = userId, + Text = request.Text, + IsMentioned = request.IsMentioned, + MentionedUserIds = request.MentionedUserIds, + ReplyToMessageId = request.ReplyToMessageId, + Attachments = request.Attachments, + CreatedAt = DateTimeOffset.Now + }; + + await recentLog.AppendAsync(msg, ct); + if (options.EmitUserEvent) + { + var userEvent = GroupChatEvent.User(groupId, userId, request.DisplayName, request.Text, msg.CreatedAt, request.Attachments.ToArray(), msg.MessageId); + // Do not attach a synthetic running topology/task to the raw user event. + // The real workflow task id is created by SessionManager/RunBatchAsEventsAsync; + // status events bind back by messageId and then carry the authoritative topology. + userEvent.ThreadId = SessionManager.BuildThreadId(msg); + await PublishIfNeededAsync(userEvent, options, ct); + yield return userEvent; + } + + tasks.CancelActiveThread(SessionManager.BuildThreadId(msg)); + var batch = await sessions.DebounceAsync(msg, ct); + if (batch is null) yield break; + await foreach (var ev in RunBatchAsEventsAsync(batch, msg.MessageId, groupId, cfg.Bot.BotDisplayName, options, ct)) + yield return ev; + } + + public async Task RunChatAsync(MessageBatch batch, string messageId, CancellationToken ct = default) + { + var cfg = await configStore.ReadAsync(ct); + var events = new List(); + await foreach (var ev in RunBatchAsEventsAsync(batch, messageId, long.TryParse(batch.GroupId, out var gid) ? gid : cfg.Bot.DefaultGroupId, cfg.Bot.BotDisplayName, new AgentWorkflowOptions { PublishToHub = false, SendToPlatform = false, EmitUserEvent = false, EmitStatusEvents = false }, ct)) + events.Add(ev); + + var trace = events.LastOrDefault(e => e.Type == "done")?.Trace ?? []; + var segments = events.Where(e => e.Type == "bot" && e.SenderId == "assistant:shiro").Select(e => e.Text).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + return new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = segments.Count > 0, + ReplySegments = segments, + Reply = string.Join("\n", segments), + Route = new RouteDecision { ShouldReply = segments.Count > 0 }, + MergedText = batch.MergedText, + Trace = trace, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + ElapsedMs = trace.Sum(x => x.ElapsedMs ?? 0) + }; + } + + private async IAsyncEnumerable RunBatchAsEventsAsync( + MessageBatch batch, + string messageId, + long groupId, + string botName, + AgentWorkflowOptions options, + [EnumeratorCancellation] CancellationToken ct) + { + Exception? failure = null; + var cancelled = false; + var terminalSeen = false; + var enumerator = ExecuteBatchAsEventsAsync(batch, messageId, groupId, botName, options, ct).GetAsyncEnumerator(ct); + + try + { + while (true) + { + GroupChatEvent? current = null; + var moved = false; + try + { + moved = await enumerator.MoveNextAsync(); + if (moved) current = enumerator.Current; + } + catch (OperationCanceledException) + { + cancelled = true; + } + catch (Exception ex) + { + failure = ex; + } + + if (cancelled || failure is not null || !moved) break; + if (current is null) continue; + terminalSeen = current.Type is "done" or "cancelled" or "failed"; + yield return current; + } + } + finally + { + try { await enumerator.DisposeAsync(); } catch { } + } + + if (terminalSeen) yield break; + + if (cancelled && tasks.GetCancellationReason(batch.TaskId) is null && !ct.IsCancellationRequested) + { + cancelled = false; + failure = new TimeoutException("deadline_exceeded"); + } + + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var cleanupToken = cleanup.Token; + if (cancelled) + { + var reason = tasks.GetCancellationReason(batch.TaskId) + ?? (ct.IsCancellationRequested ? "caller_cancelled" : "workflow_cancelled"); + tasks.Cancel(batch.TaskId, reason); + await conversations.MarkCancelledAsync(batch, reason, cleanupToken); + var trace = new List + { + new() { Step = "cancelled", Agent = "workflow-engine", Status = "cancelled", Detail = reason } + }; + await runStore.SaveAsync(new AgentWorkflowState + { + RunId = batch.TaskId, + ThreadId = batch.ThreadId, + GroupId = batch.GroupId, + UserId = batch.UserId, + MessageId = messageId, + InputText = batch.MergedText, + Phase = AgentRunPhase.Cancelled, + Error = reason, + Trace = trace + }, cleanupToken); + var ev = GroupChatEvent.Cancel(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, messageId, trace, groupId); + ev.Topology = topologyBuilder.Build(trace, "cancelled"); + await PublishIfNeededAsync(ev, options, cleanupToken); + yield return ev; + yield break; + } + + var error = failure?.Message ?? "workflow_ended_without_terminal_event"; + tasks.Fail(batch.TaskId, error); + await conversations.MarkFailedAsync(batch, error, cleanupToken); + var failedTrace = new List + { + new() { Step = "failed", Agent = "workflow-engine", Status = "error", Detail = error } + }; + await runStore.SaveAsync(new AgentWorkflowState + { + RunId = batch.TaskId, + ThreadId = batch.ThreadId, + GroupId = batch.GroupId, + UserId = batch.UserId, + MessageId = messageId, + InputText = batch.MergedText, + Phase = AgentRunPhase.Failed, + Error = error, + Trace = failedTrace + }, cleanupToken); + var failed = GroupChatEvent.Failed(DateTimeOffset.Now, error, batch.TaskId, batch.ThreadId, messageId, failedTrace, groupId); + failed.Topology = topologyBuilder.Build(failedTrace, "error"); + await PublishIfNeededAsync(failed, options, cleanupToken); + yield return failed; + } + + private async IAsyncEnumerable ExecuteBatchAsEventsAsync( + MessageBatch batch, + string messageId, + long groupId, + string botName, + AgentWorkflowOptions options, + [EnumeratorCancellation] CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + var runContext = new AgentRunContext(batch); + var runState = new AgentTaskState { TaskId = batch.TaskId, ThreadId = batch.ThreadId, GroupId = batch.GroupId, UserId = batch.UserId, MergedText = batch.MergedText }; + var lease = tasks.StartLease(runState, batch.Messages.Select(m => m.MessageId)); + using var deadline = new CancellationTokenSource(Math.Max(1000, configuredOptions.Value.Runtime.QualityTimeoutMs)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, lease.Token, deadline.Token); + ct = linked.Token; + var state = new AgentWorkflowState + { + RunId = batch.TaskId, + ThreadId = batch.ThreadId, + GroupId = batch.GroupId, + UserId = batch.UserId, + MessageId = messageId, + InputText = batch.MergedText, + Budget = new AgentRunBudget { LatencyBudgetMs = configuredOptions.Value.Runtime.QualityTimeoutMs, MaxPlannerTurns = 1, MaxToolRounds = 1, MaxReplySegments = 2 } + }; + + async Task CheckpointAsync(string? runningKey = null) + { + try { await runStore.SaveAsync(state, ct); } catch { } + try + { + if (state.Phase is not AgentRunPhase.Completed and not AgentRunPhase.Failed and not AgentRunPhase.Cancelled) + await conversations.UpdateRunningTraceAsync(batch, state.Trace, topologyBuilder.Build(state.Trace, "running", runningKey), ct); + } + catch { } + } + + void Trace(string step, string agent, string status, string detail, long? elapsed = null) + { + state.Trace.Add(new AgentTraceEvent { Step = step, Agent = agent, Status = status, Detail = detail, AtMs = sw.ElapsedMilliseconds, ElapsedMs = elapsed }); + state.UpdatedAt = DateTimeOffset.Now; + } + + void ApplyRouteBudget(RouteDecision currentRoute) + { + var budgetMs = Math.Max(1000, currentRoute.TimeoutMs); + state.Budget.LatencyBudgetMs = budgetMs; + state.Budget.MaxReplySegments = Math.Clamp(currentRoute.MaxReplySegments, 1, 2); + var remainingMs = Math.Max(1, budgetMs - (int)Math.Min(int.MaxValue, sw.ElapsedMilliseconds)); + deadline.CancelAfter(remainingMs); + } + + async Task StatusAsync(string text, string? runningKey = null) + { + if (!options.EmitStatusEvents) return null; + var ev = GroupChatEvent.Status(text, DateTimeOffset.Now, batch.TaskId, batch.ThreadId, messageId, groupId); + ev.Topology = topologyBuilder.Build(state.Trace, "running", runningKey); + await PublishIfNeededAsync(ev, options, ct); + return ev; + } + + async Task VisibleNoticeAsync(string text, string? runningKey = null) + { + if (string.IsNullOrWhiteSpace(text)) return null; + long? replySeq = null; + if (long.TryParse(batch.Messages.LastOrDefault()?.MessageId, out var parsedSeq)) replySeq = parsedSeq; + var sendResult = options.SendToPlatform ? await platform.SendAsync(groupId, text, [], replySeq?.ToString(), ct) : null; + var ev = GroupChatEvent.BotMessage(groupId, botName, text, DateTimeOffset.Now, sw.ElapsedMilliseconds, sendResult, [], null, batch.TaskId, batch.ThreadId); + ev.Topology = topologyBuilder.Build(state.Trace, "running", runningKey); + await PublishIfNeededAsync(ev, options, ct); + return ev; + } + + state.Phase = AgentRunPhase.Received; + Trace("received", "workflow-engine", "ok", $"messages={batch.Messages.Count}, thread={batch.ThreadId}"); + await conversations.MarkRunningAsync(batch, ct); + await CheckpointAsync("received"); + var runStart = await StatusAsync("任务已创建", "received"); + if (runStart is not null) yield return runStart; + + state.Phase = AgentRunPhase.Fusing; + var fusionStart = await StatusAsync("正在融合消息", "fusion"); + if (fusionStart is not null) yield return fusionStart; + var fusionSw = Stopwatch.StartNew(); + var fused = await fusionAgent.FuseAsync(batch, ct); + fusionSw.Stop(); + state.Fusion = fused; + runContext.Artifacts.Append("fusion", "message-fusion-agent", fused); + batch = MessageFusionAgent.Apply(batch, fused); + state.InputText = batch.MergedText; + Trace("fusion", "message-fusion-agent", "ok", $"type={fused.FusionType}, split={fused.ShouldSplit}, summary={fused.FusionSummary}", fusionSw.ElapsedMilliseconds); + await CheckpointAsync(); + + state.Phase = AgentRunPhase.Routing; + var routeStart = await StatusAsync("正在判断是否需要回复", "router"); + if (routeStart is not null) yield return routeStart; + var routeSw = Stopwatch.StartNew(); + var routerDecision = await router.RunAsync(batch, ct); + routeSw.Stop(); + var route = routerDecision.Route; + runContext.Artifacts.Append("route", "router-agent", routerDecision); + state.Route = route; + ApplyRouteBudget(route); + var routeStatus = routerDecision.Reason.StartsWith("router_timeout_", StringComparison.OrdinalIgnoreCase) || + routerDecision.Reason.StartsWith("router_failed:", StringComparison.OrdinalIgnoreCase) + ? "error" + : "ok"; + Trace("route", "router-agent", routeStatus, $"target={routerDecision.Target}, risk={routerDecision.InterjectionRisk}, mode={route.Mode}, web={route.WebPolicy}, reason={routerDecision.Reason}", routeSw.ElapsedMilliseconds); + await CheckpointAsync("router"); + + if (!routerDecision.ShouldReply || !route.ShouldReply) + { + state.Phase = AgentRunPhase.NoReply; + state.PlannerSummary = "skipped=router_no_reply"; + Trace("plan", "planner-agent", "skipped", $"skipped=router_no_reply; reason={routerDecision.Reason}"); + await CheckpointAsync("planner"); + + var noReply = await StatusAsync("模型选择不回复:" + route.Reason); + if (noReply is not null) yield return noReply; + var noReplyResult = new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = false, + Route = route, + MergedText = batch.MergedText, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + Trace = state.Trace, + ElapsedMs = sw.ElapsedMilliseconds + }; + if (!tasks.Complete(batch.TaskId, "", lease.Generation)) throw new OperationCanceledException("Task ownership changed."); + await conversations.MarkCompletedAsync(batch, noReplyResult, ct); + memoryWorker.Enqueue(batch, null); + state.Phase = AgentRunPhase.Completed; + await CheckpointAsync(); + var doneNoReply = GroupChatEvent.Done(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, state.Trace, sw.ElapsedMilliseconds, groupId, messageId); + doneNoReply.Topology = topologyBuilder.Build(state.Trace, "no_reply"); + await PublishIfNeededAsync(doneNoReply, options, ct); + yield return doneNoReply; + yield break; + } + + var guardSw = Stopwatch.StartNew(); + var guard = await interjectionGuard.CheckAsync(batch, route, ct); + guardSw.Stop(); + Trace("interjection-guard", "interjection-guard", guard.Suppress ? "skipped" : "ok", $"reason={guard.Reason}; detail={guard.Detail}", guardSw.ElapsedMilliseconds); + if (guard.Suppress) + { + state.Phase = AgentRunPhase.NoReply; + state.PlannerSummary = "skipped=interjection_guard"; + Trace("plan", "planner-agent", "skipped", $"skipped=interjection_guard; reason={guard.Reason}; detail={guard.Detail}"); + await CheckpointAsync("planner"); + + var noReply = await StatusAsync("模型选择不回复:" + guard.Reason); + if (noReply is not null) yield return noReply; + var noReplyResult = new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = false, + Route = route, + MergedText = batch.MergedText, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + Trace = state.Trace, + ElapsedMs = sw.ElapsedMilliseconds + }; + if (!tasks.Complete(batch.TaskId, "", lease.Generation)) throw new OperationCanceledException("Task ownership changed."); + await conversations.MarkCompletedAsync(batch, noReplyResult, ct); + memoryWorker.Enqueue(batch, null); + state.Phase = AgentRunPhase.Completed; + await CheckpointAsync(); + var doneNoReply = GroupChatEvent.Done(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, state.Trace, sw.ElapsedMilliseconds, groupId, messageId); + doneNoReply.Topology = topologyBuilder.Build(state.Trace, "no_reply"); + await PublishIfNeededAsync(doneNoReply, options, ct); + yield return doneNoReply; + yield break; + } + + var shouldRunPlanner = route.NeedMainAgent || route.NeedToolPlanning; + PlannerDecisionEnvelope? plannerDecision = null; + if (shouldRunPlanner) + { + state.Phase = AgentRunPhase.Planning; + var planningNotice = await StatusAsync("正在规划下一步", "planner"); + if (planningNotice is not null) yield return planningNotice; + state.Budget.PlannerTurns++; + var planSw = Stopwatch.StartNew(); + var plan = await planner.RunAsync(batch, routerDecision, ct); + plannerDecision = plan; + runContext.Artifacts.Append("plan", "planner-agent", plan); + planSw.Stop(); + route = plan.Route; + state.Route = route; + ApplyRouteBudget(route); + state.PlannerSummary = $"next={plan.NextAction}, task={plan.TaskType}, tools={plan.Tools.Count}, toolWeight={plan.ToolWeight:F2}, memoryWeight={plan.MemoryRecallWeight:F2}, analysisWeight={plan.AnalysisWeight:F2}"; + Trace("plan", "planner-agent", "ok", $"{state.PlannerSummary}; reason={plan.Reason}", planSw.ElapsedMilliseconds); + + state.Route = route; + await CheckpointAsync("planner"); + + if (!route.ShouldReply) + { + state.Phase = AgentRunPhase.NoReply; + var noReply = await StatusAsync("模型选择不回复:" + route.Reason); + if (noReply is not null) yield return noReply; + var noReplyResult = new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = false, + Route = route, + MergedText = batch.MergedText, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + Trace = state.Trace, + ElapsedMs = sw.ElapsedMilliseconds + }; + if (!tasks.Complete(batch.TaskId, "", lease.Generation)) throw new OperationCanceledException("Task ownership changed."); + await conversations.MarkCompletedAsync(batch, noReplyResult, ct); + memoryWorker.Enqueue(batch, null); + state.Phase = AgentRunPhase.Completed; + await CheckpointAsync(); + var doneNoReply = GroupChatEvent.Done(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, state.Trace, sw.ElapsedMilliseconds, groupId, messageId); + doneNoReply.Topology = topologyBuilder.Build(state.Trace, "no_reply"); + await PublishIfNeededAsync(doneNoReply, options, ct); + yield return doneNoReply; + yield break; + } + } + else + { + state.PlannerSummary = "skipped=router_direct_reply"; + Trace("plan", "planner-agent", "skipped", $"skipped=router_direct_reply; reason={route.Reason}"); + await CheckpointAsync(); + } + + var recallSw = Stopwatch.StartNew(); + HeavyMemoryRecallResult recall; + if (plannerDecision?.NeedsMemoryRecall == true) + { + var recallStatus = await StatusAsync("正在按语义权重读取重记忆", "memory"); + if (recallStatus is not null) yield return recallStatus; + recall = await heavyMemoryRecall.RecallIfNeededAsync(batch, true, ct); + } + else + { + recall = new HeavyMemoryRecallResult { ShouldRecall = false, Note = "planner_weight_below_threshold" }; + Trace("heavy-memory-recall", "heavy-memory-recall-agent", "skipped", $"weight={plannerDecision?.MemoryRecallWeight ?? 0:F2}"); + } + runContext.Artifacts.Append("memory-recall", "heavy-memory-recall-agent", recall); + var heavyMemoryRecallText = runContext.Artifacts.ReadLatest()?.LightMemoryText ?? ""; + recallSw.Stop(); + if (recall.ShouldRecall) + { + Trace("heavy-memory-recall", "heavy-memory-recall-agent", recall.NeedClarification ? "clarify" : "ok", $"note={recall.Note}, entries={recall.Entries.Count}", recallSw.ElapsedMilliseconds); + if (recall.NeedClarification && !string.IsNullOrWhiteSpace(recall.ClarifyingQuestion)) + { + var notice = await VisibleNoticeAsync(recall.ClarifyingQuestion, "memory"); + if (notice is not null) yield return notice; + var clarifyResult = new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = true, + Reply = recall.ClarifyingQuestion, + ReplySegments = [recall.ClarifyingQuestion], + Route = route, + MergedText = batch.MergedText, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + Trace = state.Trace, + ElapsedMs = sw.ElapsedMilliseconds + }; + await recentLog.AppendAssistantAsync(batch.GroupId, batch.UserId, recall.ClarifyingQuestion, ct); + if (!tasks.Complete(batch.TaskId, recall.ClarifyingQuestion, lease.Generation)) throw new OperationCanceledException("Task ownership changed."); + await conversations.MarkCompletedAsync(batch, clarifyResult, ct); + memoryWorker.Enqueue(batch, recall.ClarifyingQuestion); + state.Phase = AgentRunPhase.Completed; + await CheckpointAsync(); + var doneClarify = GroupChatEvent.Done(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, state.Trace, sw.ElapsedMilliseconds, groupId, messageId); + doneClarify.Topology = topologyBuilder.Build(state.Trace, "completed"); + await PublishIfNeededAsync(doneClarify, options, ct); + yield return doneClarify; + yield break; + } + } + else + { + Trace("heavy-memory-recall", "heavy-memory-recall-agent", "skipped", recall.Note, recallSw.ElapsedMilliseconds); + } + + await CheckpointAsync("context"); + var start = await StatusAsync("开始处理", "context"); + if (start is not null) yield return start; + + state.Phase = route.Tools.Count > 0 || plannerDecision?.ExecutionPlan.Nodes.Count > 0 || route.Mode == ReplyMode.Web || route.WebPolicy == WebPolicy.Required ? AgentRunPhase.ToolExecuting : AgentRunPhase.ContextBuilding; + var observations = new List(); + var toolAttachments = new List(); + WebSearchResult? web = null; + if (state.Phase == AgentRunPhase.ToolExecuting) + { + var toolStatus = await StatusAsync("开始执行子任务", "tool"); + if (toolStatus is not null) yield return toolStatus; + if (route.Tools.Count > 0) + { + var toolNotice = await VisibleNoticeAsync(string.IsNullOrWhiteSpace(route.ToolNotice) ? "我先查一下相关资料。" : route.ToolNotice, "tool"); + if (toolNotice is not null) yield return toolNotice; + } + var toolSw = Stopwatch.StartNew(); + var planForTools = new PlannerDecisionEnvelope { Route = route, Tools = route.Tools, NeedsTool = true, UserVisibleNotice = route.ToolNotice, ToolDoneNotice = route.ToolDoneNotice }; + var effectivePlan = plannerDecision ?? planForTools; + ExecutionPlanResult? executionResult = null; + if (effectivePlan.ExecutionPlan.Nodes.Count > 0) + { + runContext.Artifacts.Append("execution-plan", "planner-agent", effectivePlan.ExecutionPlan); + executionResult = await planScheduler.ExecuteAsync(effectivePlan.ExecutionPlan, effectivePlan, batch, runContext, ct); + observations.AddRange(executionResult.ToolObservations); + state.Analysis = executionResult.Analysis; + if (executionResult.HasRequiredFailure) + { + state.Analysis ??= new AnalysisResult(); + state.Analysis.Uncertainties.Add("必要工具执行失败,未取得足够可靠依据;最终回复必须明确说明该限制。"); + } + foreach (var node in executionResult.Nodes) + Trace($"subagent:{node.NodeId}", node.AgentId, node.Status, node.Error ?? "completed", node.ElapsedMs); + } + else + { + var toolResults = await toolAgent.ExecuteAsync(planForTools, batch.MergedText, batch.GroupId, ct); + foreach (var observation in toolResults) + runContext.Artifacts.Append("tool-observation", "tool-agent", observation); + observations.AddRange(toolResults); + } + toolSw.Stop(); + state.Budget.ToolRounds++; + state.ToolObservations = observations; + toolAttachments = observations.SelectMany(o => o.Attachments).ToList(); + web = BuildWebResult(observations); + Trace("tool", "tool-agent", observations.Any(o => o.Status == "error") ? "partial" : "ok", $"observations={observations.Count}, attachments={toolAttachments.Count}", toolSw.ElapsedMilliseconds); + foreach (var obs in observations) + { + var rawEv = GroupChatEvent.BotMessage(groupId, "工具观察", obs.EvidenceSummary, DateTimeOffset.Now, toolSw.ElapsedMilliseconds, null, [], null, batch.TaskId, batch.ThreadId); + rawEv.SenderName = "工具观察"; + await PublishIfNeededAsync(rawEv, options, ct); + yield return rawEv; + } + var toolOverview = BuildToolResultOverview(route, observations); + if (!string.IsNullOrWhiteSpace(toolOverview)) + { + route.ToolResultOverview = toolOverview; + var doneNotice = await VisibleNoticeAsync(toolOverview, "tool"); + if (doneNotice is not null) yield return doneNotice; + } + state.Phase = AgentRunPhase.ToolObserved; + await CheckpointAsync(); + } + + if (state.Analysis is null && observations.Count > 0 && plannerDecision?.NeedsAnalysis == true && plannerDecision.ExecutionPlan.Nodes.Count == 0) + { + state.Phase = AgentRunPhase.Analyzing; + var analysisStatus = await StatusAsync("正在分析工具结果", "analysis"); + if (analysisStatus is not null) yield return analysisStatus; + var analysisNotice = await VisibleNoticeAsync(string.IsNullOrWhiteSpace(route.AnalysisNotice) ? "我整理一下这些信息再回答。" : route.AnalysisNotice, "analysis"); + if (analysisNotice is not null) yield return analysisNotice; + var analysisSw = Stopwatch.StartNew(); + var analysisPlan = plannerDecision; + state.Analysis = await analyst.AnalyzeAsync(batch, analysisPlan, observations, ct); + runContext.Artifacts.Append("analysis", "analyst-agent", state.Analysis); + analysisSw.Stop(); + Trace("analysis", "analyst-agent", "ok", $"risks={state.Analysis.Risks.Count}, uncertainties={state.Analysis.Uncertainties.Count}", analysisSw.ElapsedMilliseconds); + await CheckpointAsync(); + } + + if (state.Analysis is not null && !string.IsNullOrWhiteSpace(state.Analysis.Conclusion)) + { + web ??= new WebSearchResult { Query = batch.MergedText }; + web.Items.Insert(0, new WebSearchItem + { + Title = "Analyst summary", + Url = string.Join(" ", state.Analysis.Citations.Take(3)), + Text = state.Analysis.Conclusion + "\n风险:" + string.Join(";", state.Analysis.Risks) + "\n不确定:" + string.Join(";", state.Analysis.Uncertainties) + }); + } + + var replyStatus = await StatusAsync("正在组织回复", "reply"); + if (replyStatus is not null) yield return replyStatus; + var pendingAttachments = new List(); + pendingAttachments.AddRange(toolAttachments); + var completedSegments = new List(); + await foreach (var ev in replyAgent.RunReplyOnlyAsync(batch, route, web, toolAttachments, heavyMemoryRecallText, state.Trace, ct)) + { + ct.ThrowIfCancellationRequested(); + if (ev.Type == "status") + { + if (ev.Trace.Count > 0) state.Trace = ev.Trace.ToList(); + var status = GroupChatEvent.Status(ev.Text, DateTimeOffset.Now, batch.TaskId, batch.ThreadId, messageId, groupId); + status.Trace = ev.Trace.Count > 0 ? ev.Trace : state.Trace; + status.Topology = topologyBuilder.Build(status.Trace, "running", string.IsNullOrWhiteSpace(ev.RunningKey) ? "reply" : ev.RunningKey); + await PublishIfNeededAsync(status, options, ct); + yield return status; + + if (ev.UserVisible) + { + var sendResult = options.SendToPlatform ? await platform.SendAsync(groupId, ev.Text, [], null, ct) : null; + var visible = GroupChatEvent.BotMessage(groupId, botName, ev.Text, DateTimeOffset.Now, ev.ElapsedMs, sendResult, [], null, batch.TaskId, batch.ThreadId); + await PublishIfNeededAsync(visible, options, ct); + yield return visible; + } + else if (ev.Text.StartsWith("[RAW_WEB_DATA]", StringComparison.Ordinal)) + { + var rawText = ev.Text["[RAW_WEB_DATA]".Length..].Trim(); + var rawEv = GroupChatEvent.BotMessage(groupId, "联网原始数据", rawText, DateTimeOffset.Now, ev.ElapsedMs, null, [], null, batch.TaskId, batch.ThreadId); + rawEv.SenderName = "联网原始数据"; + await PublishIfNeededAsync(rawEv, options, ct); + yield return rawEv; + } + } + else if (ev.Type == "attachment" && ev.Attachment is not null) + { + pendingAttachments.Add(ev.Attachment); + var outEv = GroupChatEvent.AttachmentEvent(groupId, "assistant:shiro", botName, ev.Attachment, DateTimeOffset.Now, null); + outEv.TaskId = batch.TaskId; + outEv.ThreadId = batch.ThreadId; + outEv.MessageId = messageId; + outEv.Topology = topologyBuilder.Build(state.Trace, "running", "reply"); + await PublishIfNeededAsync(outEv, options, ct); + yield return outEv; + } + else if (ev.Type == "msg-start") + { + state.Phase = AgentRunPhase.Replying; + await CheckpointAsync(); + var outEv = GroupChatEvent.BotTyping(groupId, botName, "", ev.Index, ev.ElapsedMs); + outEv.TaskId = batch.TaskId; + outEv.ThreadId = batch.ThreadId; + outEv.MessageId = messageId; + outEv.Topology = topologyBuilder.Build(state.Trace, "running", "reply"); + await PublishIfNeededAsync(outEv, options, ct); + yield return outEv; + } + else if (ev.Type == "msg-delta") + { + var outEv = GroupChatEvent.BotTyping(groupId, botName, ev.Text, ev.Index, ev.ElapsedMs, append: true); + outEv.TaskId = batch.TaskId; + outEv.ThreadId = batch.ThreadId; + outEv.MessageId = messageId; + outEv.Topology = topologyBuilder.Build(state.Trace, "running", "reply"); + await PublishIfNeededAsync(outEv, options, ct); + yield return outEv; + } + else if (ev.Type == "msg-end") + { + ct.ThrowIfCancellationRequested(); + state.Phase = AgentRunPhase.Sending; + await CheckpointAsync(); + var text = ev.Text.Trim(); + if (!string.IsNullOrWhiteSpace(text)) completedSegments.Add(text); + long? replySeq = null; + if (ev.ReplyToCurrentMessage && long.TryParse(batch.Messages.LastOrDefault()?.MessageId, out var parsedSeq)) replySeq = parsedSeq; + var sendResult = options.SendToPlatform ? await platform.SendAsync(groupId, text, pendingAttachments, replySeq?.ToString(), ct) : null; + var outEv = GroupChatEvent.BotMessage(groupId, botName, text, DateTimeOffset.Now, ev.ElapsedMs, sendResult, pendingAttachments.ToArray(), ev.Index, batch.TaskId, batch.ThreadId); + outEv.MessageId = messageId; + outEv.Topology = topologyBuilder.Build(state.Trace, "running", "reply"); + await PublishIfNeededAsync(outEv, options, ct); + yield return outEv; + pendingAttachments.Clear(); + } + else if (ev.Type == "done") + { + state.Phase = AgentRunPhase.Completed; + var finalReply = string.Join("\n", completedSegments); + state.ReplySegments = completedSegments.ToList(); + await CheckpointAsync(); + if (!string.IsNullOrWhiteSpace(finalReply)) await recentLog.AppendAssistantAsync(batch.GroupId, batch.UserId, finalReply, ct); + memoryWorker.Enqueue(batch, string.IsNullOrWhiteSpace(finalReply) ? null : finalReply); + var result = new ChatResult + { + TaskId = batch.TaskId, + MessageId = messageId, + GroupId = batch.GroupId, + UserId = batch.UserId, + ShouldReply = !string.IsNullOrWhiteSpace(finalReply), + Reply = finalReply, + ReplySegments = completedSegments, + Route = route, + MergedText = batch.MergedText, + StartedAt = batch.CreatedAt, + FinishedAt = DateTimeOffset.Now, + Trace = ev.Trace.Count > 0 ? ev.Trace : state.Trace, + ElapsedMs = sw.ElapsedMilliseconds + }; + if (!tasks.Complete(batch.TaskId, finalReply, lease.Generation)) throw new OperationCanceledException("Task ownership changed."); + await conversations.MarkCompletedAsync(batch, result, ct); + var done = GroupChatEvent.Done(DateTimeOffset.Now, batch.TaskId, batch.ThreadId, result.Trace, sw.ElapsedMilliseconds, groupId, messageId); + done.Topology = topologyBuilder.Build(result.Trace, result.ShouldReply ? "completed" : "no_reply"); + await PublishIfNeededAsync(done, options, ct); + yield return done; + } + } + } + + private static string BuildToolResultOverview(RouteDecision route, IReadOnlyList observations) + { + if (observations.Count == 0) return ""; + var ok = observations.Where(o => string.IsNullOrWhiteSpace(o.Error) && o.Status != "error").ToList(); + if (ok.Count == 0) return ""; + var first = ok.First(); + var subject = FirstNonEmpty(first.Query, first.Url, route.Tools.FirstOrDefault()?.Query, route.Tools.FirstOrDefault()?.Url); + var titles = ok.SelectMany(o => o.Items).Select(i => i.Title).Where(t => !string.IsNullOrWhiteSpace(t)).Take(3).ToList(); + var topic = titles.Count > 0 ? string.Join(";", titles) : subject; + if (string.IsNullOrWhiteSpace(topic)) topic = "相关资料"; + if (!string.IsNullOrWhiteSpace(route.ToolDoneNotice)) + return $"{route.ToolDoneNotice},主要关于:{Limit(topic, 90)}"; + return $"找到一些资料了,主要关于:{Limit(topic, 90)}"; + } + + private static string FirstNonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? ""; + + private static string Limit(string text, int max) => string.IsNullOrWhiteSpace(text) || text.Length <= max ? text : text[..max] + "…"; + + private static WebSearchResult? BuildWebResult(IReadOnlyList observations) + { + var items = observations.SelectMany(o => o.Items).ToList(); + if (items.Count == 0 && observations.All(o => string.IsNullOrWhiteSpace(o.Error))) return null; + return new WebSearchResult + { + Query = string.Join("; ", observations.Select(o => string.IsNullOrWhiteSpace(o.Query) ? o.Url : o.Query).Where(x => !string.IsNullOrWhiteSpace(x)).Take(3)), + Items = items, + Error = string.Join("; ", observations.Where(o => !string.IsNullOrWhiteSpace(o.Error)).Select(o => o.Error)) + }; + } + + private async Task PublishIfNeededAsync(GroupChatEvent ev, AgentWorkflowOptions options, CancellationToken ct) + { + if (options.PublishToHub) await hub.PublishAsync(ev, ct); + } + + +} diff --git a/MyAgent/Core/AgentWorkflowModels.cs b/MyAgent/Core/AgentWorkflowModels.cs new file mode 100644 index 0000000..52b596c --- /dev/null +++ b/MyAgent/Core/AgentWorkflowModels.cs @@ -0,0 +1,61 @@ +namespace MyAgent.Core; + +public enum AgentRunPhase +{ + Received, + Fusing, + Routing, + NoReply, + Planning, + Guarding, + ToolExecuting, + ToolObserved, + Analyzing, + ContextBuilding, + Replying, + Sending, + Completed, + Failed, + Cancelled +} + +public sealed class AgentWorkflowState +{ + public string RunId { get; set; } = ""; + public string ThreadId { get; set; } = ""; + public string GroupId { get; set; } = ""; + public string UserId { get; set; } = ""; + public string MessageId { get; set; } = ""; + public AgentRunPhase Phase { get; set; } = AgentRunPhase.Received; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now; + public string InputText { get; set; } = ""; + public FusedTurn? Fusion { get; set; } + public RouteDecision Route { get; set; } = new(); + public string PlannerSummary { get; set; } = ""; + public List ToolObservations { get; set; } = []; + public AnalysisResult? Analysis { get; set; } + public List ReplySegments { get; set; } = []; + public List PendingAttachments { get; set; } = []; + public AgentRunBudget Budget { get; set; } = new(); + public List Trace { get; set; } = []; + public string? Error { get; set; } +} + +public sealed class AgentRunBudget +{ + public int MaxPlannerTurns { get; set; } = 1; + public int MaxToolRounds { get; set; } = 1; + public int MaxReplySegments { get; set; } = 2; + public int LatencyBudgetMs { get; set; } = 25000; + public int PlannerTurns { get; set; } + public int ToolRounds { get; set; } +} + +public sealed class AgentWorkflowOptions +{ + public bool PublishToHub { get; init; } + public bool SendToPlatform { get; init; } + public bool EmitUserEvent { get; init; } = true; + public bool EmitStatusEvents { get; init; } = true; +} diff --git a/MyAgent/Core/ChatEngine.cs b/MyAgent/Core/ChatEngine.cs new file mode 100644 index 0000000..81739af --- /dev/null +++ b/MyAgent/Core/ChatEngine.cs @@ -0,0 +1,126 @@ +using MyAgent.Storage; + +namespace MyAgent.Core; + +/// +/// Thin HTTP/API adapter around the shared workflow engine. +/// The old ChatEngine used to duplicate router/planner/reply orchestration; that now lives in AgentWorkflowEngine. +/// +public sealed class ChatEngine( + RecentLogStore recentLog, + SessionManager sessionManager, + TaskRegistry taskRegistry, + ConversationRegistry conversationRegistry, + AgentWorkflowEngine workflow) +{ + public async Task HandleAsync(ChatRequest request, CancellationToken ct = default) + { + var startedAt = DateTimeOffset.Now; + var sw = System.Diagnostics.Stopwatch.StartNew(); + + var msg = new IncomingMessage + { + MessageId = request.MessageId ?? Guid.NewGuid().ToString("n"), + GroupId = request.GroupId, + UserId = request.UserId, + Text = request.Text, + IsMentioned = request.IsMentioned, + MentionedUserIds = request.MentionedUserIds, + IsRecall = request.IsRecall, + RecallMessageId = request.RecallMessageId, + ReplyToMessageId = request.ReplyToMessageId, + CreatedAt = DateTimeOffset.Now + }; + + if (msg.IsRecall) + return await HandleRecallAsync(msg, startedAt, sw, ct); + + await recentLog.AppendAsync(msg, ct); + var threadId = SessionManager.BuildThreadId(msg); + taskRegistry.CancelActiveThread(threadId); + var batch = await sessionManager.DebounceAsync(msg, ct); + if (batch is null) + { + return Finish(new ChatResult + { + MessageId = msg.MessageId, + GroupId = msg.GroupId, + UserId = msg.UserId, + ShouldReply = false, + Notes = ["superseded_by_newer_message"] + }, startedAt, sw); + } + return await workflow.RunChatAsync(batch, msg.MessageId, ct); + } + + public async Task HandleBurstAsync(BurstChatRequest request, CancellationToken ct = default) + { + var startedAt = DateTimeOffset.Now; + var sw = System.Diagnostics.Stopwatch.StartNew(); + var now = DateTimeOffset.Now; + + var messages = request.Messages + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select((text, index) => new IncomingMessage + { + MessageId = Guid.NewGuid().ToString("n"), + GroupId = request.GroupId, + UserId = request.UserId, + Text = text, + IsMentioned = request.IsMentioned, + ReplyToMessageId = request.ReplyToMessageId, + CreatedAt = now.AddMilliseconds(index * Math.Max(1, request.IntervalMs)) + }) + .ToList(); + + if (messages.Count == 0) + { + return Finish(new ChatResult + { + GroupId = request.GroupId, + UserId = request.UserId, + ShouldReply = false, + Notes = ["empty_burst"] + }, startedAt, sw); + } + + foreach (var message in messages) + await recentLog.AppendAsync(message, ct); + + taskRegistry.CancelActiveThread(SessionManager.BuildThreadId(messages[^1])); + var batch = await sessionManager.MergeManyAsync(messages, ct); + return await workflow.RunChatAsync(batch, messages[^1].MessageId, ct); + } + + private async Task HandleRecallAsync(IncomingMessage msg, DateTimeOffset startedAt, System.Diagnostics.Stopwatch sw, CancellationToken ct) + { + var recalledTaskId = !string.IsNullOrWhiteSpace(msg.RecallMessageId) && taskRegistry.TryGetTaskIdByMessage(msg.RecallMessageId, out var taskId) + ? taskId + : null; + var cancelled = !string.IsNullOrWhiteSpace(msg.RecallMessageId) && taskRegistry.CancelByMessage(msg.RecallMessageId); + if (!string.IsNullOrWhiteSpace(msg.RecallMessageId)) + { + await recentLog.RemoveByMessageIdAsync(msg.GroupId, msg.RecallMessageId, ct); + await sessionManager.RemoveMessageAsync(msg.RecallMessageId, ct); + await conversationRegistry.RemoveMessageAsync(msg.GroupId, msg.RecallMessageId, recalledTaskId, ct); + } + + return Finish(new ChatResult + { + MessageId = msg.MessageId, + GroupId = msg.GroupId, + UserId = msg.UserId, + ShouldReply = false, + Notes = [cancelled ? "cancelled_related_task" : "recall_ignored"] + }, startedAt, sw); + } + + private static ChatResult Finish(ChatResult r, DateTimeOffset startedAt, System.Diagnostics.Stopwatch sw) + { + sw.Stop(); + r.StartedAt = startedAt; + r.FinishedAt = DateTimeOffset.Now; + r.ElapsedMs = sw.ElapsedMilliseconds; + return r; + } +} diff --git a/MyAgent/Core/ConfigUiMetadata.cs b/MyAgent/Core/ConfigUiMetadata.cs new file mode 100644 index 0000000..e40cf7d --- /dev/null +++ b/MyAgent/Core/ConfigUiMetadata.cs @@ -0,0 +1,87 @@ +namespace MyAgent.Core; + +public sealed class ConfigUiMetadata +{ + public IReadOnlyList Sections { get; init; } = + [ + new() + { + Key = "openAi", + Title = "OpenAI / 模型", + Description = "OpenAI-compatible API 和各阶段模型配置。", + Fields = + [ + ConfigField.Text("openAi.baseUrl", "OpenAI Base URL", "https://api.openai.com/v1", full: false), + ConfigField.Secret("openAi.apiKey", "OpenAI API Key", full: false), + ConfigField.Model("openAi.model", "默认 Model", full: true), + ConfigField.Model("openAi.routerModel", "路由模型"), + ConfigField.Model("openAi.toolModel", "工具规划模型"), + ConfigField.Model("openAi.memoryModel", "记忆整理模型"), + ConfigField.Model("openAi.replyModel", "正式回复模型"), + ConfigField.Text("openAi.embeddingModel", "Embedding 模型", "如 text-embedding-3-small;为空则关闭语义路由", full: false) + ] + }, + new() + { + Key = "search", + Title = "搜索 / 工具", + Description = "联网搜索、图片搜索和工具相关 provider。", + Fields = + [ + ConfigField.Secret("exa.apiKey", "Exa API Key", full: true), + ConfigField.Number("exa.maxResults", "Exa Max Results", min: 1, max: 10), + ConfigField.Select("exa.mode", "Exa Mode", [new("http", "HTTP API"), new("mcp", "MCP stdio")]), + ConfigField.Checkbox("exa.enabled", "启用 Exa"), + ConfigField.Select("search.webSearchProvider", "网络搜索源", [new("combined", "Exa + Tavily 综合"), new("exa", "仅 Exa"), new("tavily", "仅 Tavily")]), + ConfigField.Secret("search.tavilyApiKey", "Tavily API Key", "用于 Tavily Search", full: true), + ConfigField.Select("media.imageSearchProvider", "图片搜索源", [new("exa-page-image", "Exa 页面提图"), new("brave-image-search", "Brave Image Search")]), + ConfigField.Secret("media.braveApiKey", "Brave Search API Key", "用于 Brave Image Search", full: true) + ] + }, + new() + { + Key = "bot", + Title = "QQ 群机器人", + Description = "回复策略、机器人身份和群配置。连接信息由 Shirobot 适配器管理。", + Fields = + [ + ConfigField.Checkbox("bot.replyOnlyWhenMentioned", "只在被提及时回复"), + ConfigField.Number("bot.botUserId", "Bot QQ 号"), + ConfigField.Text("bot.botDisplayName", "Bot 显示名", "例如:莲"), + ConfigField.Csv("bot.botAliases", "Bot 别名", "例如:Shiro, 小白", full: true), + ConfigField.Number("bot.defaultGroupId", "默认群号"), + ConfigField.Csv("bot.allowedGroupIds", "接入群号列表", "留空表示全部群", full: true) + ] + } + ]; +} + +public sealed class ConfigSectionMetadata +{ + public string Key { get; init; } = ""; + public string Title { get; init; } = ""; + public string Description { get; init; } = ""; + public IReadOnlyList Fields { get; init; } = []; +} + +public sealed class ConfigField +{ + public string Path { get; init; } = ""; + public string Label { get; init; } = ""; + public string Type { get; init; } = "text"; // text | secret | number | checkbox | select | model | csv-list + public string Placeholder { get; init; } = ""; + public bool Full { get; init; } + public int? Min { get; init; } + public int? Max { get; init; } + public IReadOnlyList Options { get; init; } = []; + + public static ConfigField Text(string path, string label, string placeholder = "", bool full = false) => new() { Path = path, Label = label, Placeholder = placeholder, Full = full, Type = "text" }; + public static ConfigField Secret(string path, string label, string placeholder = "", bool full = false) => new() { Path = path, Label = label, Placeholder = placeholder, Full = full, Type = "secret" }; + public static ConfigField Number(string path, string label, int? min = null, int? max = null, bool full = false) => new() { Path = path, Label = label, Type = "number", Min = min, Max = max, Full = full }; + public static ConfigField Checkbox(string path, string label) => new() { Path = path, Label = label, Type = "checkbox" }; + public static ConfigField Select(string path, string label, IReadOnlyList options, bool full = false) => new() { Path = path, Label = label, Type = "select", Options = options, Full = full }; + public static ConfigField Model(string path, string label, bool full = false) => new() { Path = path, Label = label, Type = "model", Full = full }; + public static ConfigField Csv(string path, string label, string placeholder = "", bool full = false) => new() { Path = path, Label = label, Type = "csv-list", Placeholder = placeholder, Full = full }; +} + +public sealed record ConfigOption(string Value, string Label); diff --git a/MyAgent/Core/ContextBuilder.cs b/MyAgent/Core/ContextBuilder.cs new file mode 100644 index 0000000..1f6e094 --- /dev/null +++ b/MyAgent/Core/ContextBuilder.cs @@ -0,0 +1,209 @@ +using Microsoft.Extensions.Options; +using MyAgent.Storage; + +namespace MyAgent.Core; + +public sealed class ContextBuilder(GroupContextStore contextStore, HeavyMemoryStore heavyMemoryStore, RecentLogStore recentLog, ConversationRegistry conversations, IOptions options) +{ + private readonly GroupContextStore _contextStore = contextStore; + private readonly HeavyMemoryStore _heavyMemoryStore = heavyMemoryStore; + private readonly RecentLogStore _recentLog = recentLog; + private readonly ConversationRegistry _conversations = conversations; + private readonly ShirobotOptions _options = options.Value; + + public async Task BuildAsync(MessageBatch batch, RouteDecision route, WebSearchResult? web, string? heavyRecallOverride = null, bool deep = false, CancellationToken ct = default) + { + var md = await _contextStore.ReadOrCreateAsync(batch.GroupId, ct); + var memberMd = await _contextStore.ReadOrCreateMemberAsync(batch.GroupId, batch.UserId, ct); + md = LimitMarkdown(md, deep ? _options.Context.MaxMarkdownChars * 2 : _options.Context.MaxMarkdownChars); + memberMd = LimitMarkdown(memberMd, deep ? _options.Context.MaxMarkdownChars : Math.Max(1200, _options.Context.MaxMarkdownChars / 2)); + var recent = await _recentLog.ReadRecentAsync(batch.GroupId, deep ? route.ContextRecentMessages * 2 : route.ContextRecentMessages, ct); + var parts = SplitContextParts(batch.GroupId, batch.UserId, md, memberMd, _contextStore.GetContextPath(batch.GroupId), _contextStore.GetMemberMemoryPath(batch.GroupId, batch.UserId), FormatRecentForQuestionContext(recent)); + var threadHistory = await _conversations.BuildThreadHistoryTextAsync(batch.ThreadId, deep ? 10 : 6, ct); + var timeContext = await _conversations.BuildTimeContextTextAsync(batch.ThreadId, DateTimeOffset.Now, ct); + var attentionContext = ConversationAttention.BuildAttentionContext(batch, recent, threadHistory); + var heavyRecall = heavyRecallOverride ?? ""; + var currentAttachments = await BuildCurrentAttachmentsAsync(batch, ct); + return new ChatPromptContext + { + GroupId = batch.GroupId, + UserId = batch.UserId, + ThreadId = batch.ThreadId, + MergedText = batch.MergedText, + GroupContextMarkdown = parts.BotProfile, + QuestionContextText = parts.QuestionContext, + MemberMemoryText = parts.MemberMemory, + TimeContextText = timeContext, + AttentionContextText = attentionContext, + HeavyMemoryRecallText = string.IsNullOrWhiteSpace(heavyRecall) ? "无" : heavyRecall, + ThreadHistoryText = threadHistory, + RecentMessages = recent, + CurrentAttachments = currentAttachments, + WebSearchResult = web, + IsDeep = deep + }; + } + + private async Task> BuildCurrentAttachmentsAsync(MessageBatch batch, CancellationToken ct) + { + var attachments = batch.Messages.SelectMany(m => m.Attachments).ToList(); + var seen = attachments.Select(AttachmentKey).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var msg in batch.Messages) + { + if (string.IsNullOrWhiteSpace(msg.ReplyToMessageId)) continue; + var replied = await _recentLog.FindByMessageIdAsync(batch.GroupId, msg.ReplyToMessageId, ct); + if (replied is null || replied.Attachments.Count == 0) continue; + foreach (var a in replied.Attachments) + { + var key = AttachmentKey(a); + if (!string.IsNullOrWhiteSpace(key) && !seen.Add(key)) continue; + attachments.Add(a); + } + } + + return attachments; + } + + private static string AttachmentKey(ChatAttachment a) + { + if (!string.IsNullOrWhiteSpace(a.MediaId)) return "media:" + a.MediaId; + if (!string.IsNullOrWhiteSpace(a.LocalPath)) return "local:" + a.LocalPath; + if (!string.IsNullOrWhiteSpace(a.Url)) return "url:" + a.Url; + if (!string.IsNullOrWhiteSpace(a.SourceUrl)) return "source:" + a.SourceUrl; + return ""; + } + + public async Task BuildPartsAsync(string groupId, string userId, CancellationToken ct = default) + { + var md = await _contextStore.ReadOrCreateAsync(groupId, ct); + var memberMd = await _contextStore.ReadMemberIfExistsAsync(groupId, userId, ct); + var recent = await _recentLog.ReadRecentAsync(groupId, 16, ct); + return SplitContextParts(groupId, userId, md, memberMd, _contextStore.GetContextPath(groupId), _contextStore.GetMemberMemoryPath(groupId, userId), FormatRecentForQuestionContext(recent)); + } + + private static ContextParts SplitContextParts(string groupId, string userId, string md, string memberMd, string groupPath, string memberPath, string liveRecentText = "") + { + return new ContextParts + { + GroupId = groupId, + UserId = userId, + BotProfile = ExtractSections(md, "机器人设定", "群长期信息", "群规则与偏好", "重要决策"), + QuestionContext = JoinNonEmpty(ExtractSections(md, "当前活跃话题"), liveRecentText), + MemberMemory = string.IsNullOrWhiteSpace(memberMd) ? "无" : ExtractSections(memberMd, "话题大纲", "领域偏好", "技术方向", "搜索消歧提示", "使用边界", "注意事项", "互动偏好", "身份与称呼", "兴趣与偏好", "技术背景", "最近相关话题"), + RecentSummary = ExtractSections(md, "最近摘要"), + RawMarkdown = md, + MemberMarkdown = memberMd, + GroupContextPath = groupPath, + MemberMemoryPath = memberPath + }; + } + + private static string JoinNonEmpty(params string[] parts) + { + var selected = parts.Where(x => !string.IsNullOrWhiteSpace(x) && !string.Equals(x.Trim(), "无", StringComparison.Ordinal)).ToList(); + return selected.Count == 0 ? "无" : string.Join("\n\n", selected); + } + + private static string FormatRecentForQuestionContext(IReadOnlyList recent) + { + if (recent.Count == 0) return ""; + var lines = recent.TakeLast(6).Select(m => $"[{m.CreatedAt:HH:mm}] {m.UserId}: {Trim(m.Text, 120)}"); + return "## 当前话题窗口(实时,短)\n" + string.Join("\n", lines); + } + + private static class ConversationAttention + { + public static string BuildAttentionContext(MessageBatch batch, IReadOnlyList recent, string threadHistory) + { + var current = batch.MergedText.Trim(); + var window = recent.TakeLast(14).ToList(); + var currentUserRecent = window.Where(m => m.UserId == batch.UserId).TakeLast(5).ToList(); + var othersAfterLastUser = MessagesAfterLastCurrentUser(window, batch.UserId); + var keywords = ExtractKeywords(current).Take(8).ToList(); + var sameTopic = window.Where(m => m.MessageId != batch.Messages.LastOrDefault()?.MessageId && HasKeywordOverlap(m.Text, keywords)).TakeLast(6).ToList(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("注意力指引(短):"); + sb.AppendLine($"- 当前群号:{batch.GroupId};所有群上下文、群员轻记忆、重记忆召回都只适用于这个群。"); + sb.AppendLine("- 只围绕当前群、当前话题和当前用户最后稳定表达回答;不要把其它群或旧话题记忆强行带入。"); + sb.AppendLine("- 指代不清或要精确时间/原始对话时,先小范围澄清或触发当前群的重记忆召回。"); + sb.AppendLine($"当前用户:{batch.UserId};关键词:{(keywords.Count == 0 ? "无" : string.Join("、", keywords.Take(5)))}"); + if (sameTopic.Count > 0) + { + sb.AppendLine("同话题近邻:"); + foreach (var m in sameTopic.TakeLast(3)) + sb.AppendLine($"[{m.CreatedAt:HH:mm}] {m.UserId}: {Trim(m.Text, 90)}"); + } + if (othersAfterLastUser.Count > 2) sb.AppendLine("提示:当前用户上一条后已有多人发言,注意不要误接其它人的话题。"); + return sb.ToString().Trim(); + } + + private static List MessagesAfterLastCurrentUser(IReadOnlyList window, string userId) + { + var idx = -1; + for (var i = 0; i < window.Count; i++) if (window[i].UserId == userId) idx = i; + return idx < 0 || idx == window.Count - 1 ? [] : window.Skip(idx + 1).Where(m => m.UserId != userId).ToList(); + } + + private static bool HasKeywordOverlap(string text, IReadOnlyList keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); + + private static IEnumerable ExtractKeywords(string text) + { + var cleaned = new string(text.Select(c => char.IsLetterOrDigit(c) || (c >= 0x4E00 && c <= 0x9FFF) ? c : ' ').ToArray()); + foreach (var part in cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (part.Length >= 2) yield return part; + if (part.Any(c => c >= 0x4E00 && c <= 0x9FFF) && part.Length >= 4) + for (var i = 0; i + 2 <= part.Length; i++) yield return part.Substring(i, 2); + } + } + + private static string Trim(string text, int max) => string.IsNullOrWhiteSpace(text) || text.Length <= max ? text : text[..max] + "..."; + } + + private static string Trim(string text, int max) => string.IsNullOrWhiteSpace(text) || text.Length <= max ? text : text[..max] + "..."; + + private static string ExtractSections(string md, params string[] names) + { + var sections = ParseSections(md); + var selected = new List(); + foreach (var name in names) + { + if (sections.TryGetValue(name, out var body) && !string.IsNullOrWhiteSpace(body)) + selected.Add($"## {name}\n{body.Trim()}"); + } + return selected.Count == 0 ? "无" : string.Join("\n\n", selected); + } + + private static Dictionary ParseSections(string md) + { + var dict = new Dictionary(); + string? current = null; + var buffer = new List(); + foreach (var line in md.Replace("\r\n", "\n").Split('\n')) + { + if (line.StartsWith("## ")) + { + if (current is not null) dict[current] = string.Join("\n", buffer); + current = line[3..].Trim(); + buffer.Clear(); + } + else if (current is not null) + { + buffer.Add(line); + } + } + if (current is not null) dict[current] = string.Join("\n", buffer); + return dict; + } + + private static string LimitMarkdown(string md, int maxChars) + { + if (md.Length <= maxChars) return md; + // 保留开头的长期规则 + 末尾最近摘要,避免只截断前半导致活跃话题丢失。 + var head = md[..(maxChars / 2)]; + var tail = md[^Math.Max(1, maxChars / 2)..]; + return head + "\n\n\n\n" + tail; + } +} diff --git a/MyAgent/Core/ConversationRegistry.cs b/MyAgent/Core/ConversationRegistry.cs new file mode 100644 index 0000000..2ae425a --- /dev/null +++ b/MyAgent/Core/ConversationRegistry.cs @@ -0,0 +1,444 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Storage; + +namespace MyAgent.Core; + +public sealed class ConversationRegistry(IOptions options, RecentLogStore recentLog, IServiceProvider services, TaskRegistry tasks) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly RecentLogStore _recentLog = recentLog; + private readonly IServiceProvider _services = services; + private readonly ConcurrentDictionary _items = new(); + private readonly SemaphoreSlim _fileLock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public async Task MarkRunningAsync(MessageBatch batch, CancellationToken ct = default) + { + if (!tasks.IsRunningOwner(batch.TaskId, batch.ThreadId)) return; + var state = GetOrCreate(batch.ThreadId, batch.GroupId, batch.UserId); + var topology = BuildTopologySafe([], "running", "received"); + lock (state) + { + state.Status = "active"; + state.LastTurnStatus = "running"; + state.LastTaskId = batch.TaskId; + state.LastMessageId = batch.Messages.LastOrDefault()?.MessageId; + state.LastMergedText = batch.MergedText; + // A new turn is running. Clear stale reply/trace from the previous completed turn, + // otherwise the frontend may show an old answer while the current message is still processing. + state.LastReply = null; + state.LastError = null; + state.LastElapsedMs = 0; + state.LastTrace = []; + state.LastTopology = topology; + state.UpdatedAt = DateTimeOffset.Now; + } + await SaveAsync(state, ct); + } + + public async Task UpdateRunningTraceAsync(MessageBatch batch, IReadOnlyList trace, AgentTopologyView? topology, CancellationToken ct = default) + { + if (!tasks.IsRunningOwner(batch.TaskId, batch.ThreadId)) return; + var state = GetOrCreate(batch.ThreadId, batch.GroupId, batch.UserId); + lock (state) + { + if (!string.Equals(state.LastTaskId, batch.TaskId, StringComparison.OrdinalIgnoreCase)) return; + if (!string.Equals(state.LastTurnStatus, "running", StringComparison.OrdinalIgnoreCase)) return; + state.LastTrace = trace.ToList(); + state.LastTopology = topology; + state.UpdatedAt = DateTimeOffset.Now; + } + await SaveAsync(state, ct); + } + + public async Task MarkCompletedAsync(MessageBatch batch, ChatResult result, CancellationToken ct = default) + { + if (!tasks.OwnsLatest(batch.TaskId, batch.ThreadId)) return; + var state = GetOrCreate(batch.ThreadId, batch.GroupId, batch.UserId); + var topology = BuildTopologySafe(result.Trace, result.ShouldReply ? "completed" : "no_reply"); + lock (state) + { + state.Status = "active"; + state.LastTurnStatus = result.ShouldReply ? "completed" : "no_reply"; + state.LastTaskId = batch.TaskId; + state.LastMessageId = batch.Messages.LastOrDefault()?.MessageId; + state.LastMergedText = batch.MergedText; + state.LastReply = result.Reply; + state.LastElapsedMs = result.ElapsedMs; + state.LastTrace = result.Trace; + state.LastTopology = topology; + if (state.Turns.Any(t => string.Equals(t.TaskId, batch.TaskId, StringComparison.OrdinalIgnoreCase))) return; + state.TurnCount += 1; + state.UpdatedAt = DateTimeOffset.Now; + state.Turns.Add(new ConversationTurn + { + TaskId = batch.TaskId, + MessageId = batch.Messages.LastOrDefault()?.MessageId ?? "", + UserText = batch.MergedText, + AssistantText = result.Reply ?? "", + ElapsedMs = result.ElapsedMs, + Trace = result.Trace, + Topology = topology, + CreatedAt = result.FinishedAt + }); + TrimTurns(state); + } + await SaveAsync(state, ct); + } + + public async Task MarkFailedAsync(MessageBatch batch, string error, CancellationToken ct = default) + { + if (!tasks.OwnsLatest(batch.TaskId, batch.ThreadId)) return; + var state = GetOrCreate(batch.ThreadId, batch.GroupId, batch.UserId); + lock (state) + { + state.Status = "active"; + state.LastTurnStatus = "failed"; + state.LastTaskId = batch.TaskId; + state.LastMessageId = batch.Messages.LastOrDefault()?.MessageId; + state.LastMergedText = batch.MergedText; + state.LastError = error; + state.LastTopology = BuildTopologySafe(state.LastTrace, "error"); + state.UpdatedAt = DateTimeOffset.Now; + } + await SaveAsync(state, ct); + } + + public async Task MarkCancelledAsync(MessageBatch batch, string reason, CancellationToken ct = default) + { + if (!tasks.OwnsLatest(batch.TaskId, batch.ThreadId)) return; + var state = GetOrCreate(batch.ThreadId, batch.GroupId, batch.UserId); + lock (state) + { + if (!string.Equals(state.LastTaskId, batch.TaskId, StringComparison.OrdinalIgnoreCase)) return; + state.Status = "active"; + state.LastTurnStatus = "cancelled"; + state.LastError = reason; + state.LastTopology = BuildTopologySafe(state.LastTrace, "cancelled"); + state.UpdatedAt = DateTimeOffset.Now; + } + await SaveAsync(state, ct); + } + + private AgentTopologyView? BuildTopologySafe(IReadOnlyList trace, string status, string? runningKey = null) + { + try + { + var topology = _services.GetService(); + return topology?.Build(trace, status, runningKey); + } + catch + { + return null; + } + } + + public async Task RemoveMessageAsync(string groupId, string messageId, string? taskId = null, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(messageId) && string.IsNullOrWhiteSpace(taskId)) return; + await LoadGroupIfNeededAsync(groupId, ct); + var changed = new List(); + foreach (var state in _items.Values.Where(x => x.GroupId == groupId)) + { + lock (state) + { + var before = state.Turns.Count; + state.Turns.RemoveAll(t => string.Equals(t.MessageId, messageId, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(taskId) && string.Equals(t.TaskId, taskId, StringComparison.OrdinalIgnoreCase))); + if (state.Turns.Count == before + && !string.Equals(state.LastTaskId, taskId, StringComparison.OrdinalIgnoreCase) + && !string.Equals(state.LastMessageId, messageId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(state.LastTaskId, taskId, StringComparison.OrdinalIgnoreCase) + || string.Equals(state.LastMessageId, messageId, StringComparison.OrdinalIgnoreCase)) + { + var last = state.Turns.LastOrDefault(); + state.LastTaskId = last?.TaskId; + state.LastMessageId = last?.MessageId; + state.LastMergedText = last?.UserText ?? ""; + state.LastReply = string.IsNullOrWhiteSpace(last?.AssistantText) ? null : last.AssistantText; + state.LastElapsedMs = last?.ElapsedMs ?? 0; + state.LastTrace = last?.Trace ?? []; + state.LastError = null; + state.LastTurnStatus = last is null ? "recalled" : string.IsNullOrWhiteSpace(last.AssistantText) ? "no_reply" : "completed"; + } + + state.TurnCount = state.Turns.Count; + state.UpdatedAt = DateTimeOffset.Now; + changed.Add(state); + } + } + + foreach (var state in changed) + await SaveAsync(state, ct); + } + + public async Task ClearGroupAsync(string groupId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(groupId)) return; + foreach (var key in _items.Where(x => x.Value.GroupId == groupId).Select(x => x.Key).ToList()) + _items.TryRemove(key, out _); + + var dir = Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", Sanitize(groupId), "threads"); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + await Task.CompletedTask; + } + + public async Task HasRecentAssistantReplyAsync(string threadId, DateTimeOffset now, TimeSpan within, CancellationToken ct = default) + { + var state = await GetAsync(threadId, ct); + if (state is null) return false; + lock (state) + { + return state.Turns + .AsEnumerable() + .Reverse() + .Any(t => !string.IsNullOrWhiteSpace(t.AssistantText) + && now - t.CreatedAt <= within); + } + } + + public async Task BuildThreadHistoryTextAsync(string threadId, int maxTurns = 6, CancellationToken ct = default) + { + var state = await GetAsync(threadId, ct); + if (state is null || state.Turns.Count == 0) return "无"; + + var sb = new StringBuilder(); + foreach (var turn in state.Turns.TakeLast(maxTurns)) + { + sb.AppendLine($"[上一轮 {turn.CreatedAt:MM-dd HH:mm}, 耗时 {turn.ElapsedMs}ms]"); + sb.AppendLine("用户:" + turn.UserText); + if (!string.IsNullOrWhiteSpace(turn.AssistantText)) + sb.AppendLine("Shiro:" + turn.AssistantText); + sb.AppendLine(); + } + return sb.ToString().Trim(); + } + + public async Task BuildRoutingContextTextAsync(string threadId, string currentText, CancellationToken ct = default, int maxRecentMessages = 6, int maxTurns = 1) + { + var state = await GetAsync(threadId, ct); + var groupId = state?.GroupId ?? ExtractGroupId(threadId); + var currentUserId = ExtractUserId(threadId); + var recent = await _recentLog.ReadRecentAsync(groupId, Math.Clamp(maxRecentMessages, 3, 12), ct); + + var sb = new StringBuilder(); + if (state is null) + { + sb.AppendLine("当前用户线程:无历史。"); + } + else + { + sb.AppendLine($"当前用户线程状态:{state.LastTurnStatus}"); + if (!string.IsNullOrWhiteSpace(state.LastMergedText) && !string.Equals(state.LastMergedText.Trim(), currentText.Trim(), StringComparison.Ordinal)) + sb.AppendLine("当前用户最近问题:" + state.LastMergedText.Trim()); + if (!string.IsNullOrWhiteSpace(state.LastReply)) + sb.AppendLine("Shiro 最近回答:" + state.LastReply.Trim()); + if (state.Turns.Count > 0) + { + sb.AppendLine("当前用户最近已完成轮次:"); + foreach (var turn in state.Turns.TakeLast(Math.Clamp(maxTurns, 1, 3))) + { + sb.AppendLine("用户:" + turn.UserText.Trim()); + if (!string.IsNullOrWhiteSpace(turn.AssistantText)) sb.AppendLine("Shiro:" + turn.AssistantText.Trim()); + } + } + } + + sb.AppendLine(); + sb.AppendLine("最近群聊消息,可能包含其他群友已经给出的回答或正在聊的话题:"); + sb.AppendLine($"当前发言者:{currentUserId}"); + if (recent.Count == 0) sb.AppendLine("无"); + foreach (var m in recent.TakeLast(Math.Clamp(maxRecentMessages, 3, 12))) + { + var speaker = m.UserId == currentUserId ? "当前发言者" : m.UserId == "assistant:shiro" ? "Shiro" : "其他群友"; + sb.AppendLine($"[{m.CreatedAt:HH:mm:ss}] {speaker}({m.UserId}): {m.Text}"); + } + sb.AppendLine(); + sb.AppendLine("注意:判断是否回复/是否查资料时,可以参考最近群聊正在聊什么;但不要把其他群友的长期偏好当成当前发言者偏好。群友独立记忆只对对应 userId 生效。"); + + return sb.ToString().Trim(); + } + + public async Task BuildTimeContextTextAsync(string threadId, DateTimeOffset now, CancellationToken ct = default) + { + var state = await GetAsync(threadId, ct); + var sb = new StringBuilder(); + sb.AppendLine($"当前时间:{now:yyyy-MM-dd HH:mm:ss zzz}"); + sb.AppendLine($"当前星期:{GetChineseDayOfWeek(now.DayOfWeek)}"); + if (state is null || state.Turns.Count == 0) + { + sb.AppendLine("这是该线程的第一轮对话,暂无上一次对话时间。"); + return sb.ToString().Trim(); + } + + var last = state.Turns[^1]; + var delta = now - last.CreatedAt; + sb.AppendLine($"上一次对话时间:{last.CreatedAt:yyyy-MM-dd HH:mm:ss zzz}"); + sb.AppendLine($"距离上一次对话:{FormatDuration(delta)}"); + sb.AppendLine($"该线程累计轮数:{state.TurnCount}"); + return sb.ToString().Trim(); + } + + private static string GetChineseDayOfWeek(DayOfWeek day) => day switch + { + DayOfWeek.Monday => "星期一", + DayOfWeek.Tuesday => "星期二", + DayOfWeek.Wednesday => "星期三", + DayOfWeek.Thursday => "星期四", + DayOfWeek.Friday => "星期五", + DayOfWeek.Saturday => "星期六", + DayOfWeek.Sunday => "星期日", + _ => day.ToString() + }; + + private static string FormatDuration(TimeSpan span) + { + if (span.TotalSeconds < 60) return $"{Math.Max(0, (int)span.TotalSeconds)} 秒"; + if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} 分 {span.Seconds} 秒"; + if (span.TotalHours < 24) return $"{(int)span.TotalHours} 小时 {span.Minutes} 分"; + return $"{(int)span.TotalDays} 天 {span.Hours} 小时"; + } + + public async Task> ListAsync(string? groupId = null, CancellationToken ct = default) + { + await LoadGroupIfNeededAsync(groupId, ct); + return _items.Values + .Where(x => string.IsNullOrWhiteSpace(groupId) || x.GroupId == groupId) + .OrderByDescending(x => x.UpdatedAt) + .Take(100) + .ToList(); + } + + private ConversationState GetOrCreate(string threadId, string groupId, string userId) + { + return _items.GetOrAdd(threadId, id => new ConversationState + { + ThreadId = id, + GroupId = groupId, + UserId = userId, + CreatedAt = DateTimeOffset.Now + }); + } + + private async Task GetAsync(string threadId, CancellationToken ct) + { + if (_items.TryGetValue(threadId, out var state)) return state; + var path = GetPathByThreadId(threadId); + if (!File.Exists(path)) return null; + var json = await File.ReadAllTextAsync(path, ct); + state = JsonSerializer.Deserialize(json, JsonOptions); + if (state is not null) _items[threadId] = state; + return state; + } + + private async Task LoadGroupIfNeededAsync(string? groupId, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(groupId)) return; + var dir = Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", Sanitize(groupId), "threads"); + if (!Directory.Exists(dir)) return; + foreach (var file in Directory.EnumerateFiles(dir, "*.json")) + { + try + { + var json = await File.ReadAllTextAsync(file, ct); + var state = JsonSerializer.Deserialize(json, JsonOptions); + if (state is not null) _items[state.ThreadId] = state; + } + catch { } + } + } + + private async Task SaveAsync(ConversationState state, CancellationToken ct) + { + var path = GetPath(state); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await _fileLock.WaitAsync(ct); + try + { + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(state, JsonOptions), ct); + } + finally + { + _fileLock.Release(); + } + } + + private string GetPath(ConversationState state) + { + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", Sanitize(state.GroupId), "threads", Sanitize(state.ThreadId) + ".json"); + } + + private string GetPathByThreadId(string threadId) + { + var groupId = ExtractGroupId(threadId); + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", Sanitize(groupId), "threads", Sanitize(threadId) + ".json"); + } + + private static string ExtractGroupId(string threadId) + { + // group:{groupId}:user:{userId} or group:{groupId}:reply:{messageId} + var parts = threadId.Split(':'); + return parts.Length >= 2 ? parts[1] : "default"; + } + + private static string ExtractUserId(string threadId) + { + var parts = threadId.Split(':'); + return parts.Length >= 4 && parts[2] == "user" ? parts[3] : "unknown"; + } + + private static string Sanitize(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) value = value.Replace(c, '_'); + return string.IsNullOrWhiteSpace(value) ? "default" : value; + } + + private static void TrimTurns(ConversationState state) + { + const int maxTurns = 30; + if (state.Turns.Count > maxTurns) + state.Turns.RemoveRange(0, state.Turns.Count - maxTurns); + } +} + +public sealed class ConversationState +{ + public string ThreadId { get; init; } = ""; + public string GroupId { get; init; } = ""; + public string UserId { get; init; } = ""; + public string Status { get; set; } = "active"; + public string LastTurnStatus { get; set; } = "idle"; + public int TurnCount { get; set; } + public string? LastTaskId { get; set; } + public string? LastMessageId { get; set; } + public string? LastMergedText { get; set; } + public string? LastReply { get; set; } + public string? LastError { get; set; } + public long LastElapsedMs { get; set; } + public List LastTrace { get; set; } = []; + public AgentTopologyView? LastTopology { get; set; } + public List Turns { get; set; } = []; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now; +} + +public sealed class ConversationTurn +{ + public string TaskId { get; set; } = ""; + public string MessageId { get; set; } = ""; + public string UserText { get; set; } = ""; + public string AssistantText { get; set; } = ""; + public long ElapsedMs { get; set; } + public List Trace { get; set; } = []; + public AgentTopologyView? Topology { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; +} diff --git a/MyAgent/Core/ExecutionPlanCompiler.cs b/MyAgent/Core/ExecutionPlanCompiler.cs new file mode 100644 index 0000000..08f49b4 --- /dev/null +++ b/MyAgent/Core/ExecutionPlanCompiler.cs @@ -0,0 +1,65 @@ +using MyAgent.Tools; + +namespace MyAgent.Core; + +public sealed class ExecutionPlanCompiler(SubagentRegistry subagents, ToolCatalog tools) +{ + public ExecutionPlan Compile(PlannerDecisionEnvelope decision) + { + var nodes = new List(); + var order = 0; + foreach (var tool in decision.Tools.Take(4)) + { + if (!tools.TryGet(tool.Name, out _) || !subagents.TryGet("tool", out _)) continue; + nodes.Add(new ExecutionPlanNode + { + Id = $"tool-{++order}", + AgentId = "tool", + Required = tool.Confidence >= 0.75, + Tool = tool, + Order = order + }); + } + + if (decision.NeedsAnalysis && subagents.TryGet("analyst", out _)) + { + nodes.Add(new ExecutionPlanNode + { + Id = "analysis-1", + AgentId = "analyst", + Required = true, + DependsOn = nodes.Select(x => x.Id).ToArray(), + Order = ++order + }); + } + + Validate(nodes); + return new ExecutionPlan { Nodes = nodes, MaxConcurrency = 2 }; + } + + private static void Validate(IReadOnlyList nodes) + { + var byId = nodes.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase); + foreach (var node in nodes) + { + if (string.IsNullOrWhiteSpace(node.Id) || string.IsNullOrWhiteSpace(node.AgentId)) + throw new InvalidOperationException("Execution plan contains an empty node id or agent id."); + foreach (var dependency in node.DependsOn) + if (!byId.ContainsKey(dependency) || string.Equals(dependency, node.Id, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"Invalid execution dependency: {node.Id} -> {dependency}"); + } + + var indegree = nodes.ToDictionary(x => x.Id, x => x.DependsOn.Count, StringComparer.OrdinalIgnoreCase); + var ready = new Queue(nodes.Where(x => indegree[x.Id] == 0).OrderBy(x => x.Order).Select(x => x.Id)); + var visited = 0; + while (ready.TryDequeue(out var id)) + { + visited++; + foreach (var child in nodes.Where(x => x.DependsOn.Contains(id, StringComparer.OrdinalIgnoreCase))) + { + if (--indegree[child.Id] == 0) ready.Enqueue(child.Id); + } + } + if (visited != nodes.Count) throw new InvalidOperationException("Execution plan contains a dependency cycle."); + } +} diff --git a/MyAgent/Core/ExecutionPlanModels.cs b/MyAgent/Core/ExecutionPlanModels.cs new file mode 100644 index 0000000..fdd74c7 --- /dev/null +++ b/MyAgent/Core/ExecutionPlanModels.cs @@ -0,0 +1,37 @@ +namespace MyAgent.Core; + +public sealed class ExecutionPlan +{ + public string PlanId { get; init; } = Guid.NewGuid().ToString("n"); + public IReadOnlyList Nodes { get; init; } = []; + public int MaxConcurrency { get; init; } = 2; +} + +public sealed class ExecutionPlanNode +{ + public string Id { get; init; } = ""; + public string AgentId { get; init; } = ""; + public bool Required { get; init; } + public IReadOnlyList DependsOn { get; init; } = []; + public RouteToolRequest? Tool { get; init; } + public int Order { get; init; } +} + +public sealed class ExecutionNodeResult +{ + public string NodeId { get; init; } = ""; + public string AgentId { get; init; } = ""; + public string Status { get; init; } = "succeeded"; + public long ElapsedMs { get; init; } + public ToolObservation? ToolObservation { get; init; } + public AnalysisResult? Analysis { get; init; } + public string? Error { get; init; } +} + +public sealed class ExecutionPlanResult +{ + public IReadOnlyList Nodes { get; init; } = []; + public IReadOnlyList ToolObservations { get; init; } = []; + public AnalysisResult? Analysis { get; init; } + public bool HasRequiredFailure { get; init; } +} diff --git a/MyAgent/Core/ExecutionPlanScheduler.cs b/MyAgent/Core/ExecutionPlanScheduler.cs new file mode 100644 index 0000000..9efdae2 --- /dev/null +++ b/MyAgent/Core/ExecutionPlanScheduler.cs @@ -0,0 +1,122 @@ +using System.Diagnostics; +using MyAgent.Agents; + +namespace MyAgent.Core; + +public sealed class ExecutionPlanScheduler( + SubagentRegistry registry, + ToolAgent toolAgent, + AnalystAgent analyst) +{ + private readonly SemaphoreSlim _globalConcurrency = new(2, 2); + + public async Task ExecuteAsync( + ExecutionPlan plan, + PlannerDecisionEnvelope planner, + MessageBatch batch, + AgentRunContext runContext, + CancellationToken ct) + { + var pending = plan.Nodes.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase); + var completed = new Dictionary(StringComparer.OrdinalIgnoreCase); + var active = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + while (pending.Count > 0 || active.Count > 0) + { + foreach (var node in pending.Values.OrderByDescending(x => x.Required).ThenBy(x => x.Order).ToArray()) + { + if (active.Count >= Math.Clamp(plan.MaxConcurrency, 1, 2)) break; + if (!node.DependsOn.All(completed.ContainsKey)) continue; + if (node.DependsOn.Any(id => plan.Nodes.First(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).Required + && completed[id].Status is "failed" or "timed_out" or "blocked")) + { + completed[node.Id] = new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "blocked", Error = "dependency_failed" }; + pending.Remove(node.Id); + continue; + } + pending.Remove(node.Id); + active[node.Id] = ExecuteNodeAsync(node, planner, batch, runContext, ct); + } + + if (active.Count == 0) + { + foreach (var node in pending.Values) + completed[node.Id] = new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "blocked", Error = "unresolved_dependencies" }; + pending.Clear(); + break; + } + + var finishedTask = await Task.WhenAny(active.Values); + var result = await finishedTask; + active.Remove(result.NodeId); + completed[result.NodeId] = result; + if (result.ToolObservation is not null) + runContext.Artifacts.Append("tool-observation", result.AgentId, result.ToolObservation); + if (result.Analysis is not null) + runContext.Artifacts.Append("analysis", result.AgentId, result.Analysis); + } + + var ordered = plan.Nodes.Select(node => completed[node.Id]).ToArray(); + return new ExecutionPlanResult + { + Nodes = ordered, + ToolObservations = ordered.Select(x => x.ToolObservation).Where(x => x is not null).Cast().ToArray(), + Analysis = ordered.Select(x => x.Analysis).LastOrDefault(x => x is not null), + HasRequiredFailure = plan.Nodes.Any(node => node.Required && completed[node.Id].Status is "failed" or "timed_out" or "blocked") + }; + } + + private async Task ExecuteNodeAsync( + ExecutionPlanNode node, + PlannerDecisionEnvelope planner, + MessageBatch batch, + AgentRunContext runContext, + CancellationToken ct) + { + if (!registry.TryGet(node.AgentId, out var descriptor)) + return new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "failed", Error = "unknown_subagent" }; + + await _globalConcurrency.WaitAsync(ct); + var sw = Stopwatch.StartNew(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(Math.Max(1000, descriptor.DefaultTimeoutMs)); + try + { + if (node.AgentId == "tool" && node.Tool is not null) + { + var envelope = new PlannerDecisionEnvelope { Route = planner.Route, Tools = [node.Tool], NeedsTool = true }; + var observation = (await toolAgent.ExecuteAsync(envelope, batch.MergedText, batch.GroupId, timeout.Token)).Single(); + return new ExecutionNodeResult + { + NodeId = node.Id, + AgentId = node.AgentId, + Status = observation.Status == "error" ? "failed" : "succeeded", + ElapsedMs = sw.ElapsedMilliseconds, + ToolObservation = observation, + Error = observation.Error + }; + } + + if (node.AgentId == "analyst") + { + var observations = runContext.Artifacts.Read(); + var analysis = await analyst.AnalyzeAsync(batch, planner, observations, timeout.Token); + return new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "succeeded", ElapsedMs = sw.ElapsedMilliseconds, Analysis = analysis }; + } + + return new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "failed", Error = "unsupported_subagent" }; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "timed_out", ElapsedMs = sw.ElapsedMilliseconds, Error = "node_timeout" }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new ExecutionNodeResult { NodeId = node.Id, AgentId = node.AgentId, Status = "failed", ElapsedMs = sw.ElapsedMilliseconds, Error = ex.Message }; + } + finally + { + _globalConcurrency.Release(); + } + } +} diff --git a/MyAgent/Core/GroupChatBridge.cs b/MyAgent/Core/GroupChatBridge.cs new file mode 100644 index 0000000..daeb02f --- /dev/null +++ b/MyAgent/Core/GroupChatBridge.cs @@ -0,0 +1,91 @@ +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Core; + +/// +/// Thin group-chat boundary adapter. Message processing now lives in AgentWorkflowEngine; +/// this bridge only handles non-reply lifecycle events that are not ordinary user messages. +/// +public sealed class GroupChatBridge( + SessionManager sessions, + RecentLogStore recentLog, + LocalConfigStore configStore, + GroupChatHub hub, + ConversationRegistry conversations, + TaskRegistry tasks) +{ + public async Task HandleRecallAsync(GroupChatRecallMessage request, CancellationToken ct = default) + { + var cfg = await configStore.ReadAsync(ct); + var groupId = request.GroupId > 0 ? request.GroupId : cfg.Bot.DefaultGroupId; + var recalledId = request.RecallMessageId ?? ""; + var recalledTaskId = tasks.TryGetTaskIdByMessage(recalledId, out var taskId) ? taskId : null; + var cancelled = !string.IsNullOrWhiteSpace(recalledId) && tasks.CancelByMessage(recalledId); + + IncomingMessage? removed = null; + if (!string.IsNullOrWhiteSpace(recalledId)) + { + removed = await recentLog.RemoveByMessageIdAsync(groupId.ToString(), recalledId, ct); + await sessions.RemoveMessageAsync(recalledId, ct); + await conversations.RemoveMessageAsync(groupId.ToString(), recalledId, recalledTaskId, ct); + hub.RemoveMessage(recalledId, recalledTaskId); + } + + var ev = GroupChatEvent.Recall(groupId, recalledId, recalledTaskId, removed?.UserId ?? request.UserId.ToString(), cancelled, DateTimeOffset.Now); + await hub.PublishAsync(ev, ct); + return ev; + } +} + +public sealed class GroupChatUserMessage +{ + public long GroupId { get; set; } + public long UserId { get; set; } = 10001; + public string DisplayName { get; set; } = "群友"; + public string Text { get; set; } = ""; + public bool IsMentioned { get; set; } + public List MentionedUserIds { get; set; } = []; + public List Attachments { get; set; } = []; + public string? MessageId { get; set; } + public string? ReplyToMessageId { get; set; } +} + +public sealed class GroupChatEvent +{ + public string Type { get; set; } = "status"; + public long GroupId { get; set; } + public string SenderId { get; set; } = ""; + public string SenderName { get; set; } = ""; + public string Text { get; set; } = ""; + public DateTimeOffset At { get; set; } = DateTimeOffset.Now; + public int? Index { get; set; } + public long? ElapsedMs { get; set; } + public bool Append { get; set; } + public ChatAttachment? Attachment { get; set; } + public ChatAttachment[] Attachments { get; set; } = []; + public MessageSendResult? Delivery { get; set; } + public string TaskId { get; set; } = ""; + public string ThreadId { get; set; } = ""; + public List Trace { get; set; } = []; + public AgentTopologyView? Topology { get; set; } + public string MessageId { get; set; } = ""; + public bool Cancelled { get; set; } + + public static GroupChatEvent User(long groupId, string senderId, string name, string text, DateTimeOffset at, ChatAttachment[]? attachments = null, string messageId = "") => new() { Type = "user", GroupId = groupId, SenderId = senderId, SenderName = string.IsNullOrWhiteSpace(name) ? senderId : name, Text = text, At = at, Attachments = attachments ?? [], MessageId = messageId }; + public static GroupChatEvent Status(string text, DateTimeOffset at, string taskId = "", string threadId = "", string messageId = "", long groupId = 0) => new() { Type = "status", GroupId = groupId, Text = text, At = at, TaskId = taskId, ThreadId = threadId, MessageId = messageId }; + public static GroupChatEvent BotTyping(long groupId, string botName, string text, int? index, long? elapsedMs, bool append = false) => new() { Type = "bot-typing", GroupId = groupId, SenderId = "assistant:shiro", SenderName = string.IsNullOrWhiteSpace(botName) ? "莲" : botName, Text = text, Index = index, ElapsedMs = elapsedMs, Append = append, At = DateTimeOffset.Now }; + public static GroupChatEvent BotMessage(long groupId, string botName, string text, DateTimeOffset at, long? elapsedMs, MessageSendResult? delivery, ChatAttachment[] attachments, int? index = null, string taskId = "", string threadId = "") => new() { Type = "bot", GroupId = groupId, SenderId = "assistant:shiro", SenderName = string.IsNullOrWhiteSpace(botName) ? "莲" : botName, Text = text, At = at, ElapsedMs = elapsedMs, Delivery = delivery, Attachments = attachments, Index = index, TaskId = taskId, ThreadId = threadId }; + public static GroupChatEvent AttachmentEvent(long groupId, string senderId, string botName, ChatAttachment attachment, DateTimeOffset at, MessageSendResult? delivery) => new() { Type = "attachment", GroupId = groupId, SenderId = senderId, SenderName = string.IsNullOrWhiteSpace(botName) ? "莲" : botName, Text = attachment.Url, Attachment = attachment, At = at, Delivery = delivery }; + public static GroupChatEvent Done(DateTimeOffset at, string taskId = "", string threadId = "", List? trace = null, long? elapsedMs = null, long groupId = 0, string messageId = "") => new() { Type = "done", GroupId = groupId, At = at, TaskId = taskId, ThreadId = threadId, MessageId = messageId, Trace = trace ?? [], ElapsedMs = elapsedMs }; + public static GroupChatEvent Cancel(DateTimeOffset at, string taskId = "", string threadId = "", string messageId = "", List? trace = null, long groupId = 0) => new() { Type = "cancelled", GroupId = groupId, At = at, TaskId = taskId, ThreadId = threadId, MessageId = messageId, Trace = trace ?? [], Cancelled = true }; + public static GroupChatEvent Failed(DateTimeOffset at, string text, string taskId = "", string threadId = "", string messageId = "", List? trace = null, long groupId = 0) => new() { Type = "failed", GroupId = groupId, At = at, Text = text, TaskId = taskId, ThreadId = threadId, MessageId = messageId, Trace = trace ?? [] }; + public static GroupChatEvent Recall(long groupId, string messageId, string? taskId, string senderId, bool cancelled, DateTimeOffset at) => new() { Type = "recall", GroupId = groupId, MessageId = messageId, TaskId = taskId ?? "", SenderId = senderId, Cancelled = cancelled, At = at }; +} + +public sealed class GroupChatRecallMessage +{ + public long GroupId { get; set; } + public long UserId { get; set; } + public string? RecallMessageId { get; set; } +} diff --git a/MyAgent/Core/GroupChatHub.cs b/MyAgent/Core/GroupChatHub.cs new file mode 100644 index 0000000..11c6c36 --- /dev/null +++ b/MyAgent/Core/GroupChatHub.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace MyAgent.Core; + +public sealed class GroupChatHub +{ + private readonly ConcurrentDictionary> _subscribers = new(); + private readonly ConcurrentQueue _recent = new(); + private const int MaxRecent = 300; + + public IReadOnlyList Recent(long groupId) + { + return _recent.Where(x => x.GroupId == 0 || x.GroupId == groupId).TakeLast(100).ToList(); + } + + public void ClearGroup(long groupId) + { + var kept = _recent + .Where(ev => ev.GroupId != 0 && ev.GroupId != groupId) + .ToList(); + while (_recent.TryDequeue(out _)) { } + foreach (var ev in kept.TakeLast(MaxRecent)) _recent.Enqueue(ev); + } + + public void RemoveMessage(string messageId, string? taskId = null) + { + if (string.IsNullOrWhiteSpace(messageId) && string.IsNullOrWhiteSpace(taskId)) return; + var kept = _recent + .Where(ev => !IsRelated(ev, messageId, taskId)) + .ToList(); + while (_recent.TryDequeue(out _)) { } + foreach (var ev in kept.TakeLast(MaxRecent)) _recent.Enqueue(ev); + } + + private static bool IsRelated(GroupChatEvent ev, string messageId, string? taskId) + { + if (!string.IsNullOrWhiteSpace(messageId) && string.Equals(ev.MessageId, messageId, StringComparison.OrdinalIgnoreCase)) return true; + if (!string.IsNullOrWhiteSpace(taskId) && string.Equals(ev.TaskId, taskId, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + public async Task PublishAsync(GroupChatEvent ev, CancellationToken ct = default) + { + _recent.Enqueue(ev); + while (_recent.Count > MaxRecent && _recent.TryDequeue(out _)) { } + foreach (var pair in _subscribers.ToArray()) + { + await pair.Value.Writer.WriteAsync(ev, ct); + } + } + + public async IAsyncEnumerable SubscribeAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateUnbounded(); + _subscribers[id] = channel; + try + { + await foreach (var ev in channel.Reader.ReadAllAsync(ct)) yield return ev; + } + finally + { + _subscribers.TryRemove(id, out _); + } + } +} diff --git a/MyAgent/Core/GroupHistorySyncService.cs b/MyAgent/Core/GroupHistorySyncService.cs new file mode 100644 index 0000000..25d0189 --- /dev/null +++ b/MyAgent/Core/GroupHistorySyncService.cs @@ -0,0 +1,66 @@ +using MyAgent.Storage; +using MyAgent.Tools; + +namespace MyAgent.Core; + +public sealed class GroupHistorySyncService( + LocalConfigStore configStore, + IGroupPlatform platform, + RecentLogStore recentLog, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await SyncOnceAsync(stoppingToken); + } + + public async Task SyncOnceAsync(CancellationToken ct = default) + { + try + { + var cfg = await configStore.ReadAsync(ct); + + var groupIds = cfg.Bot.AllowedGroupIds + .Append(cfg.Bot.DefaultGroupId) + .Where(x => x > 0) + .Distinct() + .ToList(); + var totalAdded = 0; + foreach (var groupId in groupIds) + { + var history = await platform.GetHistoryAsync(groupId, 100, ct); + var messages = history + .Where(ev => ev.Type == "user" && !string.IsNullOrWhiteSpace(ev.SenderId) && !string.IsNullOrWhiteSpace(ev.Text)) + .Select(ev => new IncomingMessage + { + MessageId = string.IsNullOrWhiteSpace(ev.MessageId) ? $"history-{ev.At.ToUnixTimeMilliseconds()}-{ev.SenderId}" : ev.MessageId, + GroupId = groupId.ToString(), + UserId = ev.SenderId, + Text = ev.Text, + IsMentioned = false, + MentionedUserIds = [], + Attachments = ev.Attachments.ToList(), + CreatedAt = ev.At + }) + .ToList(); + + var added = await recentLog.AppendMissingAsync(groupId.ToString(), messages, ct); + totalAdded += added; + if (added > 0) + logger.LogInformation("Group history sync appended {Count} missing messages for group {GroupId}", added, groupId); + else + logger.LogInformation("Group history sync completed; no missing messages for group {GroupId}", groupId); + } + return totalAdded; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Group history sync failed; continuing without startup backfill"); + return 0; + } + } +} diff --git a/MyAgent/Core/IGroupPlatform.cs b/MyAgent/Core/IGroupPlatform.cs new file mode 100644 index 0000000..2a4cc0f --- /dev/null +++ b/MyAgent/Core/IGroupPlatform.cs @@ -0,0 +1,26 @@ +using MyAgent.Tools; + +namespace MyAgent.Core; + +/// Platform capabilities needed by the agent, independent of any QQ protocol. +public interface IGroupPlatform +{ + Task SendAsync( + long groupId, + string text, + IReadOnlyList attachments, + string? replyToMessageId, + CancellationToken cancellationToken = default); + + Task> GetHistoryAsync(long groupId, int limit, CancellationToken cancellationToken = default); + + Task GetProfileAsync(long groupId, CancellationToken cancellationToken = default); +} + +public sealed class MessageSendResult +{ + public bool Ok { get; init; } + public string? MessageId { get; init; } + public DateTimeOffset? Timestamp { get; init; } + public string? Error { get; init; } +} diff --git a/MyAgent/Core/IntentWeightPolicy.cs b/MyAgent/Core/IntentWeightPolicy.cs new file mode 100644 index 0000000..de583ce --- /dev/null +++ b/MyAgent/Core/IntentWeightPolicy.cs @@ -0,0 +1,13 @@ +namespace MyAgent.Core; + +public static class IntentWeightPolicy +{ + public const double ToolThreshold = 0.5; + public const double MemoryRecallThreshold = 0.6; + public const double AnalysisThreshold = 0.65; + + public static bool ShouldUseTool(double weight) => Normalize(weight) >= ToolThreshold; + public static bool ShouldRecallMemory(double weight) => Normalize(weight) >= MemoryRecallThreshold; + public static bool ShouldAnalyze(double weight) => Normalize(weight) >= AnalysisThreshold; + public static double Normalize(double weight) => Math.Clamp(weight, 0, 1); +} diff --git a/MyAgent/Core/InterjectionGuard.cs b/MyAgent/Core/InterjectionGuard.cs new file mode 100644 index 0000000..5707bc6 --- /dev/null +++ b/MyAgent/Core/InterjectionGuard.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using MyAgent.Agents; +using MyAgent.Storage; + +namespace MyAgent.Core; + +public sealed class InterjectionGuard(IChatModelClient client, RecentLogStore recentLog, LocalConfigStore configStore, ILogger logger) +{ + private const string BotUserId = "assistant:shiro"; + private const int TimeoutMs = 1800; + public async Task CheckAsync(MessageBatch batch, RouteDecision route, CancellationToken ct = default) + { + if (!route.ShouldReply || batch.Messages.Count == 0) return InterjectionGuardResult.Allow("route_no_reply_or_empty"); + + var cfg = await configStore.ReadAsync(ct); + var botProfile = BuildBotProfile(cfg); + var botIds = BuildBotIds(cfg); + var currentText = batch.MergedText.Trim(); + var currentMentionIds = batch.Messages.SelectMany(m => m.MentionedUserIds).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + var mentionedOthers = currentMentionIds.Where(id => !botIds.Contains(id)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var mentionedBot = currentMentionIds.Any(id => botIds.Contains(id)) + || batch.Messages.Any(m => m.IsMentioned) + || HasExplicitBotName(currentText, botProfile); + + if (mentionedOthers.Count > 0 && !mentionedBot) + return InterjectionGuardResult.Suppressed("mentions_other_member", $"mentioned={string.Join(',', mentionedOthers.Take(4))}"); + + var currentIds = batch.Messages.Select(m => m.MessageId).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var last = batch.Messages.Last(); + var previousWindow = await recentLog.ReadBeforeAsync(batch.GroupId, last.CreatedAt, 12, currentIds, ct); + + if (!string.IsNullOrWhiteSpace(last.ReplyToMessageId)) + { + var replied = await recentLog.FindByMessageIdAsync(batch.GroupId, last.ReplyToMessageId, ct); + if (replied is not null && IsBot(replied, botIds)) + return InterjectionGuardResult.Allow("reply_to_bot", $"replyTo={replied.UserId}"); + if (replied is not null && !IsBot(replied, botIds) && !mentionedBot) + return InterjectionGuardResult.Suppressed("reply_to_other_member", $"replyTo={replied.UserId}"); + } + + var previous = previousWindow.LastOrDefault(); + if (previous is null) return InterjectionGuardResult.Allow("no_previous_message"); + if (mentionedBot) return InterjectionGuardResult.Allow("bot_mentioned"); + + var semantic = await AskModelAsync(batch, previous, previousWindow, route, mentionedBot, botProfile, ct); + return semantic; + } + + private async Task AskModelAsync(MessageBatch batch, IncomingMessage previous, IReadOnlyList previousWindow, RouteDecision route, bool mentionedBot, BotProfile botProfile, CancellationToken ct) + { + var system = $$""" +你是 QQ 群聊“是否插嘴”判定器。只输出 JSON。 +你的任务不是判断“这个问题能不能回答”,而是判断“{{botProfile.DisplayName}}”现在该不该主动插入回答。 + +机器人身份: +- 显示名:{{botProfile.DisplayName}} +- QQ:{{botProfile.BotUserId}} +- 别名:{{string.Join(" / ", botProfile.Aliases)}} + +核心原则: +1. 先判断对话中心:最近上下文是在围绕机器人(显示名/QQ/别名如上)展开,还是群友之间互相聊天。 +2. 特别注意中文里的“你”:如果“你”从上下文看是在指上一位群友、被回复的人、被 @ 的其他人,或者泛指群友/大家,而不是特指机器人,则 suppress=true。 +3. 当前消息如果是在接上一位群友的话、回答上一位群友、追问上一位群友、纠正上一位群友、问上一位群友“你/你们/这个”,通常 suppress=true。 +4. 如果前文刚 @ 了其他群友,后续同一发言者连续发“这个/他/你的/怎么做/用什么”等追问,默认是在问那个被 @ 的群友或延续对他的讨论,不是问机器人,除非又明确点名机器人。 +5. 机器人显示名/别名出现在项目名、仓库名、域名、包名、文件名中,不等于点名机器人。例如“shirobot.dashboard / shirobot plugin / xxx-shirobot”只是技术对象名,不能据此认为上下文围绕机器人。 +6. 如果最近一段时间没有明显群友对话密度,且当前发言者没有在 @ 或接续其他群友的话题,当前消息更像向全群提出的独立问题/求助,可以 suppress=false。 +7. 简单客观问题、术语解释、技术报错、资料查询、使用建议等,如果没人正在回答,机器人可以适当参与,suppress=false。 +8. 计算/折扣换算/单位换算/价格比较/时间换算这类低风险工具型问题,可以更主动参与;即使没有点名机器人,只要像向全群求助且上下文里有数字或可以短澄清,suppress=false。 +9. 对会影响人类判断/决策的讨论要谨慎,例如社会事件、项目选型、投资/消费、法律/协议、争议话题、技术选型/部署架构/CDN/库推荐、对某人/某项目的评价。如果这类话题正在群友之间讨论,且没有明确交给机器人,就 suppress=true,避免影响人类决策;如果是无人接的全群求助,可以短答或提示不确定。 +10. 时间因素:08:00~23:00 是热门活跃时间。若最近几分钟群友消息密集、多人连续讨论,机器人应更少插话;除非上下文明显把问题交给机器人,或当前是无人接的全群求助/客观问题/低风险计算换算。 +11. 难题不能自动抢答;但如果当前消息明显是向全群求助,而不是接某个群友的话,可以 suppress=false,给短而有用的回答。 +12. 如果用户明确 @ 机器人、回复机器人、点名显示名/QQ/别名且不是项目名/包名的一部分、或上下文连续在问机器人,suppress=false。 +13. 如果当前只是单独发送 URL/链接、截图链接、文章链接、视频链接,且没有明确让机器人总结/打开/解释/搜索/分析,默认 suppress=true;链接更可能只是群友分享或交给外部插件处理。 +14. 上一句是机器人回复只表示“可能是追问”,不是自动允许。仍要判断当前发言是否真的在追问机器人;如果像是在对其他群友解释、吐槽、转述、贴链接或开启新话题,suppress=true。 +15. 平衡原则:避免打断群友对话,但不要变成只有点名才说话;当前像全群求助且无人接时,可以参与。 + +只输出:{"suppress":true|false,"reason":"简短原因,说明你指代、上下文中心、是否简单客观问题或决策性话题"} +"""; + + var user = $$""" +最近群聊窗口(按时间从旧到新;assistant:shiro/{{botProfile.BotUserId}} 是 {{botProfile.DisplayName}}): +{{FormatWindow(previousWindow)}} + +上一句群友消息: +userId={{previous.UserId}} +time={{previous.CreatedAt:HH:mm:ss}} +text={{previous.Text}} + +当前待处理消息: +userId={{batch.UserId}} +time={{batch.Messages.Last().CreatedAt:HH:mm:ss}} +text={{batch.MergedText}} +mentionedBot={{mentionedBot}} +routeReason={{route.Reason}} +routeMode={{route.Mode}} +routeWeb={{route.WebPolicy}} + +请判断:当前消息里的“你/这个/他/上面”如果出现,指的是谁?当前上下文是否把问题交给 {{botProfile.DisplayName}}?机器人名字/别名是否只是出现在项目名、仓库名、域名或包名里?最近是否有群友密集对话、刚 @ 其他人、或当前用户正在接别人的话?这个问题是简单客观问题,还是会影响人类决策/立场/技术选型的讨论?{{botProfile.DisplayName}} 此刻该不该插嘴? +"""; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeoutMs); + try + { + var result = await client.CompleteRoutingAsync([ + new ChatMessage { Role = "system", Content = system }, + new ChatMessage { Role = "user", Content = user } + ], 0.0, 120, timeout.Token); + + var json = ExtractJson(result.Content); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var suppress = root.TryGetProperty("suppress", out var s) && s.ValueKind is JsonValueKind.True or JsonValueKind.False && s.GetBoolean(); + var reason = root.TryGetProperty("reason", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() ?? "model" : "model"; + return suppress ? InterjectionGuardResult.Suppressed("llm_contextual_reply", reason) : InterjectionGuardResult.Allow("llm_allow:" + reason); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // 插嘴判定超时时必须保守:不再因为“同一用户追问”直接放行。 + // 真正明确交给机器人的场景已在前面的 reply_to_bot / bot_mentioned / previous_is_bot 结构规则中放行。 + return InterjectionGuardResult.Suppressed("llm_timeout_conservative", $"previous={previous.UserId}"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Interjection guard failed; falling back to conservative no-interjection"); + return InterjectionGuardResult.Suppressed("llm_guard_failed_conservative", ex.Message); + } + } + + private static bool HasExplicitBotName(string text, BotProfile profile) + { + if (ContainsDisplayName(text, profile.DisplayName)) return true; + if (ContainsDisplayName(text, profile.BotUserId)) return true; + return profile.Aliases.Any(alias => ContainsDisplayName(text, alias)); + } + + private static bool ContainsDisplayName(string text, string name) + { + name = name.Trim(); + if (name.Length == 0) return false; + var idx = text.IndexOf(name, StringComparison.OrdinalIgnoreCase); + while (idx >= 0) + { + var beforeOk = idx == 0 || char.IsWhiteSpace(text[idx - 1]) || char.IsPunctuation(text[idx - 1]) || text[idx - 1] == '@'; + var end = idx + name.Length; + var afterOk = end >= text.Length || char.IsWhiteSpace(text[end]) || char.IsPunctuation(text[end]); + if (beforeOk && afterOk) return true; + idx = text.IndexOf(name, idx + name.Length, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + private static string FormatWindow(IReadOnlyList window) + { + if (window.Count == 0) return "无"; + return string.Join("\n", window.Select(m => $"[{m.CreatedAt:HH:mm:ss}] {m.UserId}: {m.Text}")); + } + + private static string DescribeWindow(IReadOnlyList window) + { + if (window.Count == 0) return "no_recent_window"; + var last = window[^1]; + var distinctUsers = window.Select(m => m.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + return $"recent={window.Count}, users={distinctUsers}, lastUser={last.UserId}, lastAt={last.CreatedAt:HH:mm:ss}"; + } + + private static HashSet BuildBotIds(RuntimeConfig cfg) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase) { BotUserId }; + if (cfg.Bot.BotUserId > 0) ids.Add(cfg.Bot.BotUserId.ToString()); + if (!string.IsNullOrWhiteSpace(cfg.Bot.BotDisplayName)) ids.Add(cfg.Bot.BotDisplayName); + foreach (var alias in cfg.Bot.BotAliases) ids.Add(alias); + return ids; + } + + private static BotProfile BuildBotProfile(RuntimeConfig cfg) + { + var aliases = cfg.Bot.BotAliases.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return new BotProfile( + string.IsNullOrWhiteSpace(cfg.Bot.BotDisplayName) ? "莲" : cfg.Bot.BotDisplayName, + cfg.Bot.BotUserId > 0 ? cfg.Bot.BotUserId.ToString() : "", + aliases); + } + + private sealed record BotProfile(string DisplayName, string BotUserId, IReadOnlyList Aliases); + + private static bool IsBot(IncomingMessage message, IReadOnlySet botIds) => botIds.Contains(message.UserId); + + private static string ExtractJson(string text) + { + text = text.Trim(); + var start = text.IndexOf('{'); + var end = text.LastIndexOf('}'); + if (start >= 0 && end > start) return text[start..(end + 1)]; + return text; + } +} + +public sealed record InterjectionGuardResult(bool Suppress, string Reason, string Detail) +{ + public static InterjectionGuardResult Allow(string reason, string detail = "") => new(false, reason, detail); + public static InterjectionGuardResult Suppressed(string reason, string detail = "") => new(true, reason, detail); +} diff --git a/MyAgent/Core/SemanticRouter.cs b/MyAgent/Core/SemanticRouter.cs new file mode 100644 index 0000000..2678840 --- /dev/null +++ b/MyAgent/Core/SemanticRouter.cs @@ -0,0 +1,194 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using MyAgent.Agents; + +namespace MyAgent.Core; + +public sealed class SemanticRouter(IChatModelClient embeddings, ILogger logger) +{ + private readonly IChatModelClient _embeddings = embeddings; + private readonly ILogger _logger = logger; + private readonly SemaphoreSlim _initLock = new(1, 1); + private readonly ConcurrentDictionary _decisionCache = new(); + private List _prototypes = []; + private bool _initialized; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + private const int MaxCacheEntries = 1024; + + public Task RefineAsync(MessageBatch batch, RouteDecision initial, CancellationToken ct = default) + { + // Tool/search/reply intent is no longer decided by hard-coded embedding prototypes. + // RouterAgent / PlannerAgent are authoritative and return structured decisions. + // Keep this service as a disabled compatibility step so existing trace UI still shows why + // semantic routing did not short-circuit the LLM router. + return Task.FromResult(SemanticRouteResult.Skipped("semantic_router_disabled; llm_router_json_authoritative", 0)); + } + + private SemanticRouteResult Apply(RoutePrototype proto, double score, RouteDecision initial, MessageBatch batch, long elapsedMs) + { + var hasAttachment = batch.Messages.Any(m => m.Attachments.Count > 0); + var high = score >= proto.Threshold; + var borderline = score >= proto.BorderlineThreshold; + var detail = $"matched={proto.Name}, score={score:F3}, threshold={proto.Threshold:F2}"; + + if (!high) + return borderline + ? SemanticRouteResult.CreateAmbiguous(detail, score, proto.Name, elapsedMs) + : SemanticRouteResult.Skipped(detail, elapsedMs, score, proto.Name); + + var route = Clone(initial); + route.NeedMainAgent = false; + route.Reason = "semantic_router:" + proto.Name + $":{score:F3}"; + route.WebPolicy = proto.WebPolicy; + route.Mode = proto.Mode; + route.ShouldReply = proto.ShouldReply; + + // Kept only for backward compatibility if semantic routing is re-enabled later. + if (hasAttachment && proto.Name is "image_question" or "reply_direct") + { + route.ShouldReply = true; + route.Mode = ReplyMode.Fast; + route.WebPolicy = WebPolicy.None; + } + + if (route.ShouldReply && route.Mode == ReplyMode.None) + route.Mode = route.WebPolicy == WebPolicy.Required ? ReplyMode.Web : ReplyMode.Fast; + + return SemanticRouteResult.CreateApplied(detail, score, proto.Name, route, elapsedMs); + } + + private async Task EnsureInitializedAsync(CancellationToken ct) + { + if (_initialized) return; + await _initLock.WaitAsync(ct); + try + { + if (_initialized) return; + var list = new List(); + foreach (var spec in Specs()) + { + foreach (var example in spec.Examples) + { + var vector = await _embeddings.CreateEmbeddingAsync(example, ct); + if (vector.Length == 0) continue; + list.Add(new RoutePrototype(spec.Name, spec.ShouldReply, spec.Mode, spec.WebPolicy, spec.Threshold, spec.BorderlineThreshold, vector)); + } + } + _prototypes = list; + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + private static IEnumerable Specs() => []; + + private static string BuildInput(MessageBatch batch) + { + var attachmentText = batch.Messages.Any(m => m.Attachments.Count > 0) ? "\n[当前消息带有图片或文件附件]" : ""; + return NormalizeText(batch.MergedText) + attachmentText; + } + + private static string NormalizeText(string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n') + .Select(x => System.Text.RegularExpressions.Regex.Replace(x, @"^\s*\d+[.、]\s*", "").Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)); + return string.Join("\n", lines).Trim(); + } + + private static string BuildCacheKey(MessageBatch batch, string input, RouteDecision initial) + { + var attach = string.Join(',', batch.Messages.SelectMany(m => m.Attachments).Select(a => a.MediaId + a.SourceUrl).OrderBy(x => x)); + return string.Join('|', "semantic-v1", batch.GroupId, batch.UserId, input.ToLowerInvariant(), attach, initial.Mode, initial.WebPolicy, batch.Messages.Any(m => m.IsMentioned)); + } + + private void SaveCache(string key, SemanticRouteResult result) + { + if (_decisionCache.Count > MaxCacheEntries) + { + foreach (var old in _decisionCache.OrderBy(x => x.Value.CreatedAt).Take(128)) + _decisionCache.TryRemove(old.Key, out _); + } + _decisionCache[key] = new CachedSemanticRoute(DateTimeOffset.Now, result.Clone()); + } + + private static double Cosine(float[] a, float[] b) + { + if (a.Length == 0 || b.Length == 0 || a.Length != b.Length) return 0; + double dot = 0, na = 0, nb = 0; + for (var i = 0; i < a.Length; i++) + { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + return na == 0 || nb == 0 ? 0 : dot / (Math.Sqrt(na) * Math.Sqrt(nb)); + } + + private static RouteDecision Clone(RouteDecision source) => new() + { + ShouldReply = source.ShouldReply, + Mode = source.Mode, + WebPolicy = source.WebPolicy, + NeedMainAgent = source.NeedMainAgent, + NeedToolPlanning = source.NeedToolPlanning, + NeedLlmFusion = source.NeedLlmFusion, + Temperature = source.Temperature, + TimeoutMs = source.TimeoutMs, + ContextRecentMessages = source.ContextRecentMessages, + Reason = source.Reason, + ToolNotice = source.ToolNotice, + ToolDoneNotice = source.ToolDoneNotice, + WebReasons = source.WebReasons.ToList(), + Tools = source.Tools.Select(t => new RouteToolRequest { Name = t.Name, Query = t.Query, Url = t.Url, Reason = t.Reason, MaxResults = t.MaxResults }).ToList() + }; + + private sealed record RouteSpec(string Name, bool ShouldReply, ReplyMode Mode, WebPolicy WebPolicy, double Threshold, double BorderlineThreshold, string[] Examples); + private sealed record RoutePrototype(string Name, bool ShouldReply, ReplyMode Mode, WebPolicy WebPolicy, double Threshold, double BorderlineThreshold, float[] Vector); + private sealed record CachedSemanticRoute(DateTimeOffset CreatedAt, SemanticRouteResult Result); +} + +public sealed class SemanticRouteResult +{ + public bool Applied { get; init; } + public bool Ambiguous { get; init; } + public bool CacheHit { get; set; } + public string Status { get; init; } = "skipped"; + public string Detail { get; init; } = ""; + public double Confidence { get; init; } + public string MatchedRoute { get; init; } = ""; + public long ElapsedMs { get; init; } + public RouteDecision? Decision { get; init; } + + public SemanticRouteResult Clone() => new() + { + Applied = Applied, + Ambiguous = Ambiguous, + CacheHit = CacheHit, + Status = Status, + Detail = Detail, + Confidence = Confidence, + MatchedRoute = MatchedRoute, + ElapsedMs = ElapsedMs, + Decision = Decision is null ? null : new RouteDecision + { + ShouldReply = Decision.ShouldReply, + Mode = Decision.Mode, + WebPolicy = Decision.WebPolicy, + NeedMainAgent = Decision.NeedMainAgent, + NeedLlmFusion = Decision.NeedLlmFusion, + Temperature = Decision.Temperature, + TimeoutMs = Decision.TimeoutMs, + ContextRecentMessages = Decision.ContextRecentMessages, + Reason = Decision.Reason, + WebReasons = Decision.WebReasons.ToList() + } + }; + + public static SemanticRouteResult CreateApplied(string detail, double confidence, string route, RouteDecision decision, long elapsedMs) => new() { Applied = true, Status = "ok", Detail = detail, Confidence = confidence, MatchedRoute = route, Decision = decision, ElapsedMs = elapsedMs }; + public static SemanticRouteResult CreateAmbiguous(string detail, double confidence, string route, long elapsedMs) => new() { Ambiguous = true, Status = "ambiguous", Detail = detail, Confidence = confidence, MatchedRoute = route, ElapsedMs = elapsedMs }; + public static SemanticRouteResult Skipped(string detail, long elapsedMs, double confidence = 0, string route = "") => new() { Status = "skipped", Detail = detail, Confidence = confidence, MatchedRoute = route, ElapsedMs = elapsedMs }; +} diff --git a/MyAgent/Core/SessionManager.cs b/MyAgent/Core/SessionManager.cs new file mode 100644 index 0000000..4081373 --- /dev/null +++ b/MyAgent/Core/SessionManager.cs @@ -0,0 +1,171 @@ +using Microsoft.Extensions.Options; + +namespace MyAgent.Core; + +public sealed class SessionManager(IOptions options, TimeProvider timeProvider) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly Dictionary _pending = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task DebounceAsync(IncomingMessage message, CancellationToken ct = default) + { + var threadId = BuildThreadId(message); + CancellationTokenSource supersede; + long revision; + + await _lock.WaitAsync(ct); + try + { + if (!_pending.TryGetValue(threadId, out var pending)) + { + pending = new PendingThread(); + _pending[threadId] = pending; + } + + var now = message.CreatedAt; + pending.Messages.RemoveAll(m => (now - m.CreatedAt).TotalSeconds > _options.Fusion.SameUserMergeWindowSec); + pending.Messages.Add(message); + var max = Math.Max(1, _options.Fusion.MaxMergedMessages); + if (pending.Messages.Count > max) + pending.Messages.RemoveRange(0, pending.Messages.Count - max); + + pending.DelayCts?.Cancel(); + supersede = new CancellationTokenSource(); + pending.DelayCts = supersede; + revision = ++pending.Revision; + } + finally + { + _lock.Release(); + } + + try + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, supersede.Token); + await Task.Delay(TimeSpan.FromMilliseconds(Math.Max(0, _options.Fusion.DebounceMs)), timeProvider, linked.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + supersede.Dispose(); + return null; + } + catch + { + await _lock.WaitAsync(CancellationToken.None); + try + { + if (_pending.TryGetValue(threadId, out var pending) && pending.Revision == revision && ReferenceEquals(pending.DelayCts, supersede)) + _pending.Remove(threadId); + } + finally + { + _lock.Release(); + } + supersede.Dispose(); + throw; + } + + await _lock.WaitAsync(CancellationToken.None); + try + { + if (ct.IsCancellationRequested) + { + if (_pending.TryGetValue(threadId, out var cancelledPending) && cancelledPending.Revision == revision && ReferenceEquals(cancelledPending.DelayCts, supersede)) + _pending.Remove(threadId); + ct.ThrowIfCancellationRequested(); + } + if (!_pending.TryGetValue(threadId, out var pending) || pending.Revision != revision || !ReferenceEquals(pending.DelayCts, supersede)) + return null; + + var stableMessages = pending.Messages.ToList(); + _pending.Remove(threadId); + return CreateBatch(stableMessages, threadId); + } + finally + { + _lock.Release(); + supersede.Dispose(); + } + } + + public Task MergeManyAsync(IReadOnlyList messages, CancellationToken ct = default) + { + if (messages.Count == 0) throw new ArgumentException("messages is empty", nameof(messages)); + ct.ThrowIfCancellationRequested(); + var stableMessages = messages.ToList(); + return Task.FromResult(CreateBatch(stableMessages, BuildThreadId(stableMessages[^1]))); + } + + public async Task ClearGroupAsync(string groupId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(groupId)) return; + await _lock.WaitAsync(ct); + try + { + foreach (var key in _pending.Keys.Where(k => k.StartsWith($"group:{groupId}:", StringComparison.OrdinalIgnoreCase)).ToList()) + { + if (_pending.Remove(key, out var pending)) pending.DelayCts?.Cancel(); + } + } + finally + { + _lock.Release(); + } + } + + public async Task RemoveMessageAsync(string messageId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(messageId)) return; + await _lock.WaitAsync(ct); + try + { + var emptyThreads = new List(); + foreach (var pair in _pending) + { + pair.Value.Messages.RemoveAll(m => string.Equals(m.MessageId, messageId, StringComparison.OrdinalIgnoreCase)); + if (pair.Value.Messages.Count == 0) emptyThreads.Add(pair.Key); + } + foreach (var threadId in emptyThreads) + { + if (_pending.Remove(threadId, out var pending)) pending.DelayCts?.Cancel(); + } + } + finally + { + _lock.Release(); + } + } + + public static string BuildThreadId(IncomingMessage message) + { + if (!string.IsNullOrWhiteSpace(message.ReplyToMessageId)) + return $"group:{message.GroupId}:user:{message.UserId}:reply:{message.ReplyToMessageId}"; + return $"group:{message.GroupId}:user:{message.UserId}"; + } + + private static string RawJoin(IReadOnlyList messages) + { + return string.Join("\n", messages.Select((m, i) => messages.Count == 1 ? m.Text.Trim() : $"{i + 1}. {m.Text.Trim()}")); + } + + private static MessageBatch CreateBatch(IReadOnlyList messages, string threadId) + { + var lastMessage = messages[^1]; + return new MessageBatch + { + GroupId = lastMessage.GroupId, + UserId = lastMessage.UserId, + ThreadId = threadId, + Messages = messages.ToArray(), + MergedText = RawJoin(messages) + }; + } + + private sealed class PendingThread + { + public List Messages { get; } = []; + public long Revision { get; set; } + public CancellationTokenSource? DelayCts { get; set; } + } +} diff --git a/MyAgent/Core/ShirobotOptions.cs b/MyAgent/Core/ShirobotOptions.cs new file mode 100644 index 0000000..56d3c67 --- /dev/null +++ b/MyAgent/Core/ShirobotOptions.cs @@ -0,0 +1,67 @@ +namespace MyAgent.Core; + +public sealed class ShirobotOptions +{ + public string DataDir { get; set; } = "data"; + public OpenAiOptions OpenAi { get; set; } = new(); + public ExaOptions Exa { get; set; } = new(); + public FusionOptions Fusion { get; set; } = new(); + public ContextOptions Context { get; set; } = new(); + public MemoryOptions Memory { get; set; } = new(); + public RuntimeOptions Runtime { get; set; } = new(); + public WebCacheOptions WebCache { get; set; } = new(); +} + +public sealed class OpenAiOptions +{ + public string BaseUrl { get; set; } = "https://api.openai.com/v1"; + public string Model { get; set; } = "gpt-4o-mini"; + public string? ApiKey { get; set; } +} + +public sealed class ExaOptions +{ + public bool Enabled { get; set; } = true; + public string? ApiKey { get; set; } + public int MaxResults { get; set; } = 5; + public string Mode { get; set; } = "http"; // http | mcp +} + +public sealed class FusionOptions +{ + public int DebounceMs { get; set; } = 450; + public int SameUserMergeWindowSec { get; set; } = 8; + public int MaxMergedMessages { get; set; } = 8; +} + +public sealed class ContextOptions +{ + public int RecentMessages { get; set; } = 30; + public int MaxMarkdownChars { get; set; } = 6000; + public int MaxAnswerChars { get; set; } = 1200; +} + +public sealed class MemoryOptions +{ + public bool Enabled { get; set; } = true; + public int CompressEveryMessages { get; set; } = 80; + public int CompressEveryMinutes { get; set; } = 30; + public int ContextSoftLimitChars { get; set; } = 12000; +} + +public sealed class RuntimeOptions +{ + public int PerGroupMaxLlmTasks { get; set; } = 2; + public int PerUserPerGroupMaxTasks { get; set; } = 1; + public int FastTimeoutMs { get; set; } = 15000; + public int WebTimeoutMs { get; set; } = 25000; + public int QualityTimeoutMs { get; set; } = 45000; +} + +public sealed class WebCacheOptions +{ + public bool Enabled { get; set; } = true; + public int SearchTtlMinutes { get; set; } = 30; + public int FetchTtlMinutes { get; set; } = 120; + public int MaxEntryTextChars { get; set; } = 12000; +} diff --git a/MyAgent/Core/SubagentRegistry.cs b/MyAgent/Core/SubagentRegistry.cs new file mode 100644 index 0000000..2097e7d --- /dev/null +++ b/MyAgent/Core/SubagentRegistry.cs @@ -0,0 +1,38 @@ +using System.Text; + +namespace MyAgent.Core; + +public sealed record SubagentDescriptor( + string Id, + string Purpose, + ContextScope ContextScope, + int DefaultTimeoutMs, + int MaxInstancesPerPlan); + +public sealed class SubagentRegistry +{ + private readonly IReadOnlyDictionary _items; + + public SubagentRegistry() + { + var descriptors = new[] + { + new SubagentDescriptor("tool", "执行受控工具并返回证据或附件。", ContextScope.CurrentTurn | ContextScope.Attachments | ContextScope.UpstreamArtifacts, 15000, 4), + new SubagentDescriptor("analyst", "综合多个证据,识别风险、冲突与不确定性。", ContextScope.CurrentTurn | ContextScope.UpstreamArtifacts, 12000, 1), + new SubagentDescriptor("memory-recall", "按需召回当前群的历史对话记忆。", ContextScope.CurrentTurn | ContextScope.HeavyMemory, 12000, 1) + }; + _items = descriptors.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList List() => _items.Values.OrderBy(x => x.Id, StringComparer.Ordinal).ToArray(); + + public bool TryGet(string id, out SubagentDescriptor descriptor) => _items.TryGetValue(id, out descriptor!); + + public string FormatForPlanner() + { + var text = new StringBuilder(); + foreach (var item in List()) + text.AppendLine($"- {item.Id}: {item.Purpose} maxInstances={item.MaxInstancesPerPlan}"); + return text.ToString().Trim(); + } +} diff --git a/MyAgent/Core/TaskRegistry.cs b/MyAgent/Core/TaskRegistry.cs new file mode 100644 index 0000000..3622a09 --- /dev/null +++ b/MyAgent/Core/TaskRegistry.cs @@ -0,0 +1,186 @@ +namespace MyAgent.Core; + +public sealed record TaskLease( + string TaskId, + string ThreadId, + long Generation, + bool CancelledPrevious, + CancellationToken Token); + +public sealed class TaskRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary _tasks = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _activeByThread = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _generationByThread = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _cancellations = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _taskByMessage = new(StringComparer.OrdinalIgnoreCase); + + public TaskLease StartLease(AgentTaskState task, IEnumerable messageIds) + { + lock (_gate) + { + var cancelledPrevious = false; + if (_activeByThread.TryGetValue(task.ThreadId, out var oldTaskId) && !string.Equals(oldTaskId, task.TaskId, StringComparison.OrdinalIgnoreCase)) + { + CancelLocked(oldTaskId, "cancelled_by_newer_message"); + cancelledPrevious = true; + } + + var generation = _generationByThread.TryGetValue(task.ThreadId, out var current) ? current + 1 : 1; + _generationByThread[task.ThreadId] = generation; + task.Generation = generation; + task.Status = "running"; + task.CancellationReason = null; + task.Error = null; + task.FinishedAt = null; + + var cts = new CancellationTokenSource(); + _tasks[task.TaskId] = task; + _cancellations[task.TaskId] = cts; + foreach (var id in messageIds.Where(id => !string.IsNullOrWhiteSpace(id))) + _taskByMessage[id] = task.TaskId; + _activeByThread[task.ThreadId] = task.TaskId; + + return new TaskLease(task.TaskId, task.ThreadId, generation, cancelledPrevious, cts.Token); + } + } + + public (bool CancelledPrevious, CancellationToken Token) Start(AgentTaskState task, IEnumerable messageIds) + { + var lease = StartLease(task, messageIds); + return (lease.CancelledPrevious, lease.Token); + } + + public bool CancelActiveThread(string threadId, string reason = "cancelled_by_newer_message") + { + lock (_gate) + return _activeByThread.TryGetValue(threadId, out var taskId) && CancelLocked(taskId, reason); + } + + public bool IsCurrent(TaskLease lease) + { + lock (_gate) + return _activeByThread.TryGetValue(lease.ThreadId, out var active) + && string.Equals(active, lease.TaskId, StringComparison.OrdinalIgnoreCase) + && _tasks.TryGetValue(lease.TaskId, out var task) + && task.Generation == lease.Generation + && string.Equals(task.Status, "running", StringComparison.OrdinalIgnoreCase); + } + + public bool IsRunningOwner(string taskId, string threadId) + { + lock (_gate) + return _activeByThread.TryGetValue(threadId, out var active) + && string.Equals(active, taskId, StringComparison.OrdinalIgnoreCase) + && _tasks.TryGetValue(taskId, out var task) + && string.Equals(task.Status, "running", StringComparison.OrdinalIgnoreCase); + } + + public bool OwnsLatest(string taskId, string threadId) + { + lock (_gate) + return _tasks.TryGetValue(taskId, out var task) + && string.Equals(task.ThreadId, threadId, StringComparison.OrdinalIgnoreCase) + && _generationByThread.TryGetValue(threadId, out var generation) + && task.Generation == generation; + } + + public bool IsCancelled(string taskId) + { + lock (_gate) + return _tasks.TryGetValue(taskId, out var task) && string.Equals(task.Status, "cancelled", StringComparison.OrdinalIgnoreCase); + } + + public bool Complete(string taskId, string result, long? generation = null) + { + lock (_gate) + { + if (!_tasks.TryGetValue(taskId, out var task) || !string.Equals(task.Status, "running", StringComparison.OrdinalIgnoreCase)) return false; + if (generation.HasValue && task.Generation != generation.Value) return false; + task.Status = "completed"; + task.Result = result; + task.UpdatedAt = DateTimeOffset.Now; + task.FinishedAt = task.UpdatedAt; + RemoveActiveLocked(task); + DisposeCancellationLocked(taskId); + return true; + } + } + + public bool Fail(string taskId, string error, long? generation = null) + { + lock (_gate) + { + if (!_tasks.TryGetValue(taskId, out var task) || !string.Equals(task.Status, "running", StringComparison.OrdinalIgnoreCase)) return false; + if (generation.HasValue && task.Generation != generation.Value) return false; + task.Status = "failed"; + task.Error = error; + task.UpdatedAt = DateTimeOffset.Now; + task.FinishedAt = task.UpdatedAt; + RemoveActiveLocked(task); + DisposeCancellationLocked(taskId); + return true; + } + } + + public bool CancelByMessage(string messageId, string reason = "cancelled_by_recall") + { + lock (_gate) + return _taskByMessage.TryGetValue(messageId, out var taskId) && CancelLocked(taskId, reason); + } + + public bool TryGetTaskIdByMessage(string messageId, out string taskId) + { + lock (_gate) return _taskByMessage.TryGetValue(messageId, out taskId!); + } + + public bool Cancel(string taskId, string reason) + { + lock (_gate) return CancelLocked(taskId, reason); + } + + public bool TryGet(string taskId, out AgentTaskState? task) + { + lock (_gate) return _tasks.TryGetValue(taskId, out task); + } + + public string? GetCancellationReason(string taskId) + { + lock (_gate) + return _tasks.TryGetValue(taskId, out var task) ? task.CancellationReason : null; + } + + public IReadOnlyList List() + { + lock (_gate) + return _tasks.Values.OrderByDescending(x => x.CreatedAt).Take(100).ToList(); + } + + private bool CancelLocked(string taskId, string reason) + { + if (!_tasks.TryGetValue(taskId, out var task) || !string.Equals(task.Status, "running", StringComparison.OrdinalIgnoreCase)) return false; + if (_cancellations.TryGetValue(taskId, out var cts)) + { + try { cts.Cancel(); } catch { } + } + task.Status = "cancelled"; + task.CancellationReason = reason; + task.UpdatedAt = DateTimeOffset.Now; + task.FinishedAt = task.UpdatedAt; + RemoveActiveLocked(task); + DisposeCancellationLocked(taskId); + return true; + } + + private void RemoveActiveLocked(AgentTaskState task) + { + if (_activeByThread.TryGetValue(task.ThreadId, out var active) && string.Equals(active, task.TaskId, StringComparison.OrdinalIgnoreCase)) + _activeByThread.Remove(task.ThreadId); + } + + private void DisposeCancellationLocked(string taskId) + { + if (_cancellations.Remove(taskId, out var cts)) cts.Dispose(); + } +} diff --git a/MyAgent/Core/TurnFusionModels.cs b/MyAgent/Core/TurnFusionModels.cs new file mode 100644 index 0000000..79378cf --- /dev/null +++ b/MyAgent/Core/TurnFusionModels.cs @@ -0,0 +1,31 @@ +namespace MyAgent.Core; + +public enum TurnFusionType +{ + Single, + Supplement, + Correction, + MultiQuestion, + AttachmentWithCaption, + Ambiguous +} + +public sealed class FusedTurn +{ + public string GroupId { get; set; } = ""; + public string UserId { get; set; } = ""; + public string ThreadId { get; set; } = ""; + public string TaskId { get; set; } = ""; + public IReadOnlyList Messages { get; set; } = []; + public string StableText { get; set; } = ""; + public string FusionSummary { get; set; } = ""; + public TurnFusionType FusionType { get; set; } = TurnFusionType.Single; + public bool ShouldSplit { get; set; } + public List MessageRoles { get; set; } = []; +} + +public sealed class FusedMessageRole +{ + public string MessageId { get; set; } = ""; + public string Role { get; set; } = ""; +} diff --git a/MyAgent/Core/WorkflowUiMetadata.cs b/MyAgent/Core/WorkflowUiMetadata.cs new file mode 100644 index 0000000..f1c13ca --- /dev/null +++ b/MyAgent/Core/WorkflowUiMetadata.cs @@ -0,0 +1,67 @@ +namespace MyAgent.Core; + +public sealed class WorkflowUiMetadata +{ + public IReadOnlyList StreamEventTypes { get; init; } = ["user", "status", "attachment", "bot-typing", "bot", "done", "failed", "recall", "cancelled"]; + + public IReadOnlyDictionary StatusLabels { get; init; } = new Dictionary + { + ["ok"] = "完成", + ["done"] = "完成", + ["completed"] = "完成", + ["no_reply"] = "不回复", + ["suppress"] = "压制", + ["cache_hit"] = "缓存命中", + ["running"] = "进行中", + ["skipped"] = "跳过", + ["pending"] = "等待", + ["error"] = "失败", + ["failed"] = "失败", + ["timed_out"] = "超时", + ["blocked"] = "已阻断", + ["partial"] = "部分成功", + ["ambiguous"] = "不确定", + ["queued"] = "已入队" + }; + + public IReadOnlyDictionary AgentLabels { get; init; } = new Dictionary + { + ["workflow-engine"] = "状态机", + ["message-fusion-agent"] = "消息融合", + ["router-agent"] = "群聊路由", + ["interjection-guard"] = "插嘴判定", + ["planner-agent"] = "任务规划", + ["tool-agent"] = "工具执行", + ["analyst-agent"] = "证据分析", + ["reply-model"] = "回复模型", + ["reply-agent"] = "回复表达", + ["context-builder"] = "上下文", + ["web_search"] = "联网搜索", + ["image_search"] = "图片搜索", + ["web_fetch"] = "网页读取", + ["media-service"] = "媒体服务", + ["platform"] = "Shirobot" + }; + + public IReadOnlyList WorkflowSteps { get; init; } = + [ + new() { Key = "received", Name = "接收消息", Steps = ["received"], Pending = "等待消息进入状态机。" }, + new() { Key = "fusion", Name = "消息融合", Steps = ["fusion"], Pending = "融合短时间连续消息,判断补充/修正/多问题。" }, + new() { Key = "router", Name = "群聊路由", Steps = ["route", "interjection-guard"], Pending = "判断当前消息是否问机器人、是否应该进入后续流程。" }, + new() { Key = "planner", Name = "任务规划", Steps = ["plan"], Pending = "选择直接回复、工具调用、分析或澄清。" }, + new() { Key = "tool", Name = "工具执行", Steps = ["tool"], Pending = "按工具目录执行 web_fetch/web_search/image_search 等能力。" }, + new() { Key = "analysis", Name = "证据分析", Steps = ["analysis"], Pending = "重问题会汇总工具证据、风险和不确定性。" }, + new() { Key = "context", Name = "上下文构建", Steps = ["context"], Pending = "加载群设定、线程历史、最近消息和附件。" }, + new() { Key = "reply", Name = "最终回复", Steps = ["reply", "reply-first-token"], Pending = "等待 ReplyAgent 输出 QQ 分段回复。" }, + new() { Key = "memory", Name = "记忆整理", Steps = [], Pending = "等待回复完成后入队。", Virtual = true } + ]; +} + +public sealed class WorkflowStepMetadata +{ + public string Key { get; init; } = ""; + public string Name { get; init; } = ""; + public IReadOnlyList Steps { get; init; } = []; + public string Pending { get; init; } = ""; + public bool Virtual { get; init; } +} diff --git a/MyAgent/GlobalUsings.cs b/MyAgent/GlobalUsings.cs new file mode 100644 index 0000000..6486374 --- /dev/null +++ b/MyAgent/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; diff --git a/MyAgent/Models/Contracts.cs b/MyAgent/Models/Contracts.cs new file mode 100644 index 0000000..c169f8c --- /dev/null +++ b/MyAgent/Models/Contracts.cs @@ -0,0 +1,223 @@ +namespace MyAgent; + +public sealed class ChatRequest +{ + public string GroupId { get; set; } = "demo-group"; + public string UserId { get; set; } = "demo-user"; + public string Text { get; set; } = ""; + public string? MessageId { get; set; } + public string? ReplyToMessageId { get; set; } + public bool IsMentioned { get; set; } + public List MentionedUserIds { get; set; } = []; + public bool IsRecall { get; set; } + public string? RecallMessageId { get; set; } +} + +public sealed class BurstChatRequest +{ + public string GroupId { get; set; } = "demo-group"; + public string UserId { get; set; } = "demo-user"; + public List Messages { get; set; } = []; + public int IntervalMs { get; set; } = 300; + public bool IsMentioned { get; set; } + public string? ReplyToMessageId { get; set; } +} + +public sealed class ChatResult +{ + public string TaskId { get; set; } = ""; + public string MessageId { get; set; } = ""; + public string GroupId { get; set; } = ""; + public string UserId { get; set; } = ""; + public bool ShouldReply { get; set; } + public string? Reply { get; set; } + public List ReplySegments { get; set; } = []; + public List Attachments { get; set; } = []; + public RouteDecision Route { get; set; } = new(); + public string MergedText { get; set; } = ""; + public bool CancelledPrevious { get; set; } + public bool QueuedQualityFollowup { get; set; } + public long ElapsedMs { get; set; } + public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.Now; + public DateTimeOffset FinishedAt { get; set; } = DateTimeOffset.Now; + public List Trace { get; set; } = []; + public List Notes { get; set; } = []; +} + +public sealed class ChatAttachment +{ + public string Type { get; set; } = "image"; + public string MediaId { get; set; } = ""; + public string Url { get; set; } = ""; + public string LocalPath { get; set; } = ""; + public string SourceUrl { get; set; } = ""; + public string PageUrl { get; set; } = ""; + public string Mime { get; set; } = ""; + public long Size { get; set; } + public string Title { get; set; } = ""; + public string TextContent { get; set; } = ""; +} + +public sealed class AgentTraceEvent +{ + public string Step { get; set; } = ""; + public string Agent { get; set; } = ""; + public string Status { get; set; } = "ok"; + public string Detail { get; set; } = ""; + public long AtMs { get; set; } + public long? ElapsedMs { get; set; } +} + +public sealed class IncomingMessage +{ + public string MessageId { get; init; } = Guid.NewGuid().ToString("n"); + public string GroupId { get; init; } = ""; + public string UserId { get; init; } = ""; + public string Text { get; init; } = ""; + public string? ReplyToMessageId { get; init; } + public bool IsMentioned { get; init; } + public IReadOnlyList MentionedUserIds { get; init; } = []; + public bool IsRecall { get; init; } + public string? RecallMessageId { get; init; } + public List Attachments { get; init; } = []; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now; +} + +public sealed class MessageBatch +{ + public string GroupId { get; init; } = ""; + public string UserId { get; init; } = ""; + public string ThreadId { get; init; } = ""; + public string TaskId { get; init; } = Guid.NewGuid().ToString("n"); + public IReadOnlyList Messages { get; init; } = []; + public string MergedText { get; init; } = ""; + public bool Ready { get; init; } = true; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now; +} + +public enum ReplyMode +{ + None, + Fast, + Web, + AsyncQuality +} + +public enum WebPolicy +{ + None, + Optional, + Required +} + +public sealed class RouteDecision +{ + public bool ShouldReply { get; set; } = true; + public ReplyMode Mode { get; set; } = ReplyMode.Fast; + public WebPolicy WebPolicy { get; set; } = WebPolicy.None; + public bool NeedMainAgent { get; set; } + public bool NeedToolPlanning { get; set; } + public bool NeedLlmFusion { get; set; } + public double Temperature { get; set; } = 0.65; + public int TimeoutMs { get; set; } = 15000; + public int ContextRecentMessages { get; set; } = 30; + public int MaxReplySegments { get; set; } = 1; + public string Reason { get; set; } = "rule"; + public string ToolNotice { get; set; } = ""; + public string ToolDoneNotice { get; set; } = ""; + public string ToolResultOverview { get; set; } = ""; + public string AnalysisNotice { get; set; } = ""; + public List WebReasons { get; set; } = []; + public List Tools { get; set; } = []; +} + +public sealed class RouteToolRequest +{ + public string Name { get; set; } = ""; // web_search | web_fetch | image_search + public string Query { get; set; } = ""; + public string Url { get; set; } = ""; + public string Reason { get; set; } = ""; + public int MaxResults { get; set; } = 5; + public double Confidence { get; set; } +} + +public sealed class ChatPromptContext +{ + public string GroupId { get; init; } = ""; + public string UserId { get; init; } = ""; + public string ThreadId { get; init; } = ""; + public string MergedText { get; init; } = ""; + public string GroupContextMarkdown { get; init; } = ""; + public string QuestionContextText { get; init; } = ""; + public string MemberMemoryText { get; init; } = ""; + public string TimeContextText { get; init; } = ""; + public string AttentionContextText { get; init; } = ""; + public string HeavyMemoryRecallText { get; init; } = ""; + public string ThreadHistoryText { get; init; } = ""; + public IReadOnlyList RecentMessages { get; init; } = []; + public IReadOnlyList CurrentAttachments { get; init; } = []; + public WebSearchResult? WebSearchResult { get; init; } + public bool IsDeep { get; init; } +} + +public sealed class ContextParts +{ + public string GroupId { get; set; } = ""; + public string UserId { get; set; } = ""; + public string BotProfile { get; set; } = ""; + public string QuestionContext { get; set; } = ""; + public string MemberMemory { get; set; } = ""; + public string RecentSummary { get; set; } = ""; + public string RawMarkdown { get; set; } = ""; + public string MemberMarkdown { get; set; } = ""; + public string GroupContextPath { get; set; } = ""; + public string MemberMemoryPath { get; set; } = ""; +} + +public sealed class ChatMessage +{ + public string Role { get; set; } = "user"; + public string Content { get; set; } = ""; + public List ImageUrls { get; set; } = []; +} + +public sealed class LlmResult +{ + public string Content { get; set; } = ""; + public bool IsFallback { get; set; } + public string? Error { get; set; } + public List Trace { get; set; } = []; +} + +public sealed class WebSearchResult +{ + public string Query { get; set; } = ""; + public List Items { get; set; } = []; + public string? Error { get; set; } + public bool FromCache { get; set; } +} + +public sealed class WebSearchItem +{ + public string Title { get; set; } = ""; + public string Url { get; set; } = ""; + public string Text { get; set; } = ""; + public bool FromCache { get; set; } +} + +public sealed class AgentTaskState +{ + public string TaskId { get; init; } = ""; + public string ThreadId { get; init; } = ""; + public string GroupId { get; init; } = ""; + public string UserId { get; init; } = ""; + public string Status { get; set; } = "running"; + public long Generation { get; set; } + public string MergedText { get; init; } = ""; + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.Now; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now; + public string? Result { get; set; } + public string? Error { get; set; } + public string? CancellationReason { get; set; } + public DateTimeOffset? FinishedAt { get; set; } +} diff --git a/MyAgent/MyAgent.csproj b/MyAgent/MyAgent.csproj new file mode 100644 index 0000000..8d1e8e5 --- /dev/null +++ b/MyAgent/MyAgent.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + MyAgent + + + + + + + + + diff --git a/MyAgent/Properties/AssemblyInfo.cs b/MyAgent/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..13cbb51 --- /dev/null +++ b/MyAgent/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MyAgent.Tests")] diff --git a/MyAgent/Storage/GroupContextStore.cs b/MyAgent/Storage/GroupContextStore.cs new file mode 100644 index 0000000..4744d89 --- /dev/null +++ b/MyAgent/Storage/GroupContextStore.cs @@ -0,0 +1,259 @@ +using Microsoft.Extensions.Options; +using MyAgent.Core; + +namespace MyAgent.Storage; + +public sealed class ContextClearAllResult +{ + public string GroupId { get; set; } = ""; + public string Markdown { get; set; } = ""; + public IReadOnlyList Deleted { get; set; } = []; + public string Message { get; set; } = ""; +} + +public sealed class GroupContextStore(IOptions options) +{ + private readonly ShirobotOptions _options = options.Value; + + public string GetGroupDir(string groupId) + { + var safe = SafePathSegment.Normalize(groupId, nameof(groupId)); + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", safe); + } + + public string GetContextPath(string groupId) => Path.Combine(GetGroupDir(groupId), "context.md"); + + public string GetMemberMemoryDir(string groupId) => Path.Combine(GetGroupDir(groupId), "members"); + + public string GetMemberMemoryPath(string groupId, string userId) => Path.Combine(GetMemberMemoryDir(groupId), SafePathSegment.Normalize(userId, nameof(userId)) + ".md"); + + public async Task ReadOrCreateAsync(string groupId, CancellationToken ct = default) + { + var path = GetContextPath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (!File.Exists(path)) + { + await File.WriteAllTextAsync(path, CreateDefaultMarkdown(groupId), ct); + } + return await File.ReadAllTextAsync(path, ct); + } + + public async Task WriteAsync(string groupId, string markdown, CancellationToken ct = default) + { + var path = GetContextPath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, markdown, ct); + } + + public async Task ReadOrCreateMemberAsync(string groupId, string userId, CancellationToken ct = default) + { + var path = GetMemberMemoryPath(groupId, userId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (!File.Exists(path)) + { + await File.WriteAllTextAsync(path, CreateDefaultMemberMarkdown(groupId, userId), ct); + } + return await File.ReadAllTextAsync(path, ct); + } + + public async Task ReadMemberIfExistsAsync(string groupId, string userId, CancellationToken ct = default) + { + var path = GetMemberMemoryPath(groupId, userId); + return File.Exists(path) ? await File.ReadAllTextAsync(path, ct) : ""; + } + + public async Task WriteMemberAsync(string groupId, string userId, string markdown, CancellationToken ct = default) + { + var path = GetMemberMemoryPath(groupId, userId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, markdown, ct); + } + + public async Task ResetMemberAsync(string groupId, string userId, CancellationToken ct = default) + { + var path = GetMemberMemoryPath(groupId, userId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (File.Exists(path)) + { + var dir = Path.GetDirectoryName(path)!; + File.Copy(path, Path.Combine(dir, $"{Sanitize(userId)}.{DateTimeOffset.Now:yyyyMMdd-HHmmss}.md.bak"), overwrite: true); + File.Copy(path, path + ".bak", overwrite: true); + } + + var markdown = CreateDefaultMemberMarkdown(groupId, userId); + await File.WriteAllTextAsync(path, markdown, ct); + return markdown; + } + + public Task DeleteMemberAsync(string groupId, string userId, bool backup = true, CancellationToken ct = default) + { + var path = GetMemberMemoryPath(groupId, userId); + if (!File.Exists(path)) return Task.FromResult(false); + if (backup) + { + var dir = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(dir); + File.Copy(path, Path.Combine(dir, $"{Sanitize(userId)}.deleted.{DateTimeOffset.Now:yyyyMMdd-HHmmss}.md.bak"), overwrite: true); + File.Copy(path, path + ".deleted.bak", overwrite: true); + } + File.Delete(path); + return Task.FromResult(true); + } + + public async Task> ListMembersAsync(string groupId, CancellationToken ct = default) + { + var dir = GetMemberMemoryDir(groupId); + if (!Directory.Exists(dir)) return []; + var list = new List(); + foreach (var file in Directory.EnumerateFiles(dir, "*.md")) + { + var info = new FileInfo(file); + var text = await File.ReadAllTextAsync(file, ct); + list.Add(new MemberMemoryInfo + { + UserId = Path.GetFileNameWithoutExtension(file), + Path = file, + UpdatedAt = info.LastWriteTime, + Bytes = info.Length, + Preview = Preview(text) + }); + } + return list.OrderByDescending(x => x.UpdatedAt).ToList(); + } + + public async Task ResetAsync(string groupId, CancellationToken ct = default) + { + var path = GetContextPath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (File.Exists(path)) + { + var dir = Path.GetDirectoryName(path)!; + File.Copy(path, Path.Combine(dir, $"context.{DateTimeOffset.Now:yyyyMMdd-HHmmss}.md.bak"), overwrite: true); + File.Copy(path, path + ".bak", overwrite: true); + } + + var markdown = CreateDefaultMarkdown(groupId); + await File.WriteAllTextAsync(path, markdown, ct); + return markdown; + } + + public async Task ClearAllContextAsync(string groupId, CancellationToken ct = default) + { + var groupDir = GetGroupDir(groupId); + Directory.CreateDirectory(groupDir); + + var deleted = new List(); + var contextPath = GetContextPath(groupId); + if (File.Exists(contextPath)) + { + File.Delete(contextPath); + deleted.Add("context.md"); + } + + foreach (var file in Directory.EnumerateFiles(groupDir, "context*.md.bak").Concat(Directory.EnumerateFiles(groupDir, "context.md.bak")).Distinct()) + { + File.Delete(file); + deleted.Add(Path.GetFileName(file)); + } + + var membersDir = GetMemberMemoryDir(groupId); + if (Directory.Exists(membersDir)) + { + Directory.Delete(membersDir, recursive: true); + deleted.Add("members/"); + } + + foreach (var name in new[] { "heavy-memory.jsonl", "light-recall-cache.md" }) + { + var path = Path.Combine(groupDir, name); + if (!File.Exists(path)) continue; + File.Delete(path); + deleted.Add(name); + } + + var heavyBackupDir = Path.Combine(groupDir, "heavy-memory-backups"); + if (Directory.Exists(heavyBackupDir)) + { + Directory.Delete(heavyBackupDir, recursive: true); + deleted.Add("heavy-memory-backups/"); + } + + await File.WriteAllTextAsync(contextPath, "", ct); + return new ContextClearAllResult + { + GroupId = groupId, + Markdown = "", + Deleted = deleted, + Message = deleted.Count == 0 + ? "上下文已是空状态,context.md 保持为空" + : $"已清空上下文、群友轻记忆、重记忆、召回暂存和相关备份,共 {deleted.Count} 项;context.md 已置空" + }; + } + + public static string CreateDefaultMarkdown(string groupId) => $$""" +# 群上下文:{{groupId}} + +## 机器人设定 +- 名字:莲 +- QQ:3522911154 +- 风格:自然、简洁、像群友,不要太官方。 +- 原则:不知道就说不知道;不要刷屏;不要乱编;记住自己在 QQ 群里,不是网页客服。 + +## 群长期信息 +- 只保存长期稳定的大纲、群约定和重要背景;不要保存聊天流水、临时细节或噪音。 + +## 当前活跃话题 +- 暂无。 + +## 群规则与偏好 +- 暂无。 + +## 重要决策 +- 默认前台一次 LLM 快速回答。 +- 实时性、二次元新番/游戏/活动、版本/兼容性等问题,需要考虑联网搜索。 +- 群里有链接/视频解析插件;群友单独发 B站、YouTube、抖音、图片链接时,通常不要主动回复,除非对方明确问莲或要求解析。 +- 回答前要看最近群消息;如果是群友在回答另一个群友,莲不要抢话,可以在有价值时短短补充或纠错。 +- 连续纠正消息需要以最后稳定版本为准,避免把中间口误当事实。 + +## 最近摘要 +- 只保留当前阶段话题大纲,不记录细枝末节;需要精确时间节点、原始对话或细节时,应按需查询重记忆。 +"""; + + public static string CreateDefaultMemberMarkdown(string groupId, string userId) => $$""" +# 群友记忆:{{userId}} + +群:{{groupId}} + +## 话题大纲 +- 只记录本群/该群友长期反复讨论过的事情大纲;省去细枝末节。需要精确时间、原始对话、具体决策过程时查询重记忆。 + +## 领域偏好 +- 暂无。 + +## 技术方向 +- 暂无。 + +## 搜索消歧提示 +- 暂无。 + +## 使用边界 +- 暂无。 + +## 注意事项 +- 轻记忆只保存大纲、稳定偏好和搜索消歧;不要保存回答风格、临时细节、聊天流水或隐私信息;不确定就留空。 +"""; + + private static string Preview(string text) + { + text = text.Replace("\r\n", "\n").Trim(); + if (text.Length <= 220) return text; + return text[..220] + "..."; + } + + private static string Sanitize(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) + value = value.Replace(c, '_'); + return string.IsNullOrWhiteSpace(value) ? "default" : value; + } +} diff --git a/MyAgent/Storage/HeavyMemoryStore.cs b/MyAgent/Storage/HeavyMemoryStore.cs new file mode 100644 index 0000000..be62631 --- /dev/null +++ b/MyAgent/Storage/HeavyMemoryStore.cs @@ -0,0 +1,387 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Core; + +namespace MyAgent.Storage; + +public sealed class HeavyMemoryStore(IOptions options) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly SemaphoreSlim _lock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; + private static readonly TimeSpan LightRecallTtl = TimeSpan.FromHours(2); + + public string GetHeavyMemoryPath(string groupId) => Path.Combine(GetGroupDir(groupId), "heavy-memory.jsonl"); + public string GetHeavyMemoryBackupDir(string groupId) => Path.Combine(GetGroupDir(groupId), "heavy-memory-backups"); + public string GetLightRecallCachePath(string groupId) => Path.Combine(GetGroupDir(groupId), "light-recall-cache.md"); + + public async Task> ReadAllAsync(string groupId, CancellationToken ct = default) + { + var path = GetHeavyMemoryPath(groupId); + if (!File.Exists(path)) return []; + var list = new List(); + foreach (var line in await File.ReadAllLinesAsync(path, ct)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var entry = JsonSerializer.Deserialize(line, JsonOptions); + if (entry is not null) list.Add(entry); + } + catch { } + } + return list.OrderBy(x => x.StartAt).ToList(); + } + + public async Task AppendEntriesAsync(string groupId, IEnumerable entries, CancellationToken ct = default) + { + var path = GetHeavyMemoryPath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + Directory.CreateDirectory(GetHeavyMemoryBackupDir(groupId)); + + await _lock.WaitAsync(ct); + try + { + var existing = File.Exists(path) + ? new HashSet((await File.ReadAllLinesAsync(path, ct)).Select(TryReadId).Where(x => !string.IsNullOrWhiteSpace(x))!, StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var added = 0; + await using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(stream, Encoding.UTF8); + foreach (var raw in entries) + { + var entry = Normalize(groupId, raw); + if (existing.Contains(entry.Id)) continue; + await writer.WriteLineAsync(JsonSerializer.Serialize(entry, JsonOptions)); + await WriteMarkdownBackupAsync(groupId, entry, ct); + existing.Add(entry.Id); + added++; + } + return added; + } + finally + { + _lock.Release(); + } + } + + public async Task> SearchAsync(string groupId, string query, int limit = 3, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) return []; + var path = GetHeavyMemoryPath(groupId); + if (!File.Exists(path)) return []; + + var terms = ExtractTerms(query).Distinct(StringComparer.OrdinalIgnoreCase).Take(12).ToList(); + if (terms.Count == 0) return []; + + // Prefer ripgrep against the single heavy-memory file. This avoids loading the entire archive + // into prompt/runtime memory and prevents accidental traversal outside the requested group file. + var rgEntries = await SearchWithRipgrepAsync(path, query, terms, ct); + var source = rgEntries.Count > 0 ? rgEntries : await SearchByCappedLineScanAsync(path, terms, ct); + + return source + .Select(e => new { Entry = e, Score = Score(e, query, terms) }) + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.Entry.EndAt) + .Take(Math.Clamp(limit, 1, 8)) + .Select(x => x.Entry) + .ToList(); + } + + public async Task WriteLightRecallCacheAsync(string groupId, string query, IReadOnlyList entries, CancellationToken ct = default) + { + var path = GetLightRecallCachePath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, FormatRecallText(query, entries), ct); + } + + public static string FormatRecallText(string query, IReadOnlyList entries) + { + var sb = new StringBuilder(); + sb.AppendLine("# 轻记忆暂存:重记忆召回"); + sb.AppendLine($"生成时间:{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); + sb.AppendLine($"触发问题:{query.Trim()}"); + sb.AppendLine(); + sb.AppendLine("说明:以下内容是本轮从重记忆中按需读取的片段,用于最近对话;一般情况下不要主动展开重记忆。若证据不足,先问一个小澄清问题。"); + sb.AppendLine(); + + if (entries.Count == 0) + { + sb.AppendLine("未找到匹配的重记忆片段。"); + } + else + { + for (var i = 0; i < entries.Count; i++) + { + var e = entries[i]; + sb.AppendLine($"## 召回片段 {i + 1}:{e.Topic}"); + sb.AppendLine($"- 时间节点:{e.StartAt:yyyy-MM-dd HH:mm:ss zzz} ~ {e.EndAt:yyyy-MM-dd HH:mm:ss zzz}"); + sb.AppendLine($"- 参与者:{string.Join("、", e.Participants)}"); + sb.AppendLine($"- 关键词:{string.Join("、", e.Keywords)}"); + sb.AppendLine($"- 摘要:{e.Summary}"); + sb.AppendLine("- 原始对话:"); + sb.AppendLine("```text"); + sb.AppendLine(Trim(e.RawDialogue, 4000)); + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + public async Task ReadLightRecallCacheAsync(string groupId, CancellationToken ct = default) + { + var path = GetLightRecallCachePath(groupId); + if (!File.Exists(path)) return ""; + var info = new FileInfo(path); + if (DateTimeOffset.Now - info.LastWriteTime > LightRecallTtl) return ""; + return await File.ReadAllTextAsync(path, ct); + } + + private static async Task> SearchWithRipgrepAsync(string filePath, string query, IReadOnlyList terms, CancellationToken ct) + { + var rg = ResolveRipgrepPath(); + if (rg is null) return []; + + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var term in terms.Where(x => x.Length >= 2).Take(8)) + { + if (byId.Count >= 40) break; + var lines = await RunRipgrepAsync(rg, filePath, term, ct); + foreach (var line in lines) + { + var json = ExtractJsonLineFromRgOutput(line); + if (string.IsNullOrWhiteSpace(json)) continue; + try + { + var entry = JsonSerializer.Deserialize(json, JsonOptions); + if (entry is not null && !string.IsNullOrWhiteSpace(entry.Id)) byId[entry.Id] = entry; + } + catch { } + } + } + + // Exact query gets one final pass, but still scoped to the single file. + if (byId.Count < 8 && !terms.Contains(query, StringComparer.OrdinalIgnoreCase)) + { + var lines = await RunRipgrepAsync(rg, filePath, query, ct); + foreach (var line in lines) + { + var json = ExtractJsonLineFromRgOutput(line); + if (string.IsNullOrWhiteSpace(json)) continue; + try + { + var entry = JsonSerializer.Deserialize(json, JsonOptions); + if (entry is not null && !string.IsNullOrWhiteSpace(entry.Id)) byId[entry.Id] = entry; + } + catch { } + } + } + + return byId.Values.ToList(); + } + + private static async Task> SearchByCappedLineScanAsync(string filePath, IReadOnlyList terms, CancellationToken ct) + { + // Fallback only: bounded scan of the newest tail, not the whole archive. + const int maxLines = 1200; + var lines = await File.ReadAllLinesAsync(filePath, ct); + var matched = new List(); + foreach (var line in lines.TakeLast(maxLines)) + { + if (!terms.Any(t => line.Contains(t, StringComparison.OrdinalIgnoreCase))) continue; + try + { + var entry = JsonSerializer.Deserialize(line, JsonOptions); + if (entry is not null) matched.Add(entry); + } + catch { } + } + return matched; + } + + private static async Task> RunRipgrepAsync(string rgPath, string filePath, string pattern, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(pattern) || !File.Exists(filePath)) return []; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(3)); + + var psi = new ProcessStartInfo + { + FileName = rgPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("--fixed-strings"); + psi.ArgumentList.Add("--ignore-case"); + psi.ArgumentList.Add("--color"); + psi.ArgumentList.Add("never"); + psi.ArgumentList.Add("--no-heading"); + psi.ArgumentList.Add("--line-number"); + psi.ArgumentList.Add("--max-count"); + psi.ArgumentList.Add("40"); + psi.ArgumentList.Add("--"); + psi.ArgumentList.Add(pattern); + psi.ArgumentList.Add(filePath); + + try + { + using var process = Process.Start(psi); + if (process is null) return []; + var outputTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + await process.WaitForExitAsync(timeout.Token); + var output = await outputTask; + return output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Take(80).ToList(); + } + catch + { + return []; + } + } + + private static string? ResolveRipgrepPath() + { + string[] candidates = [ + @"D:\download\rg\rg.exe", + @"C:\Users\JustMe\Downloads\rg.exe", + "rg" + ]; + foreach (var candidate in candidates) + { + if (candidate == "rg") return candidate; + if (File.Exists(candidate)) return candidate; + } + return null; + } + + private static string ExtractJsonLineFromRgOutput(string line) + { + var idx = line.IndexOf(':'); + if (idx < 0 || idx == line.Length - 1) return ""; + return line[(idx + 1)..].Trim(); + } + + private string GetGroupDir(string groupId) + { + var safe = SafePathSegment.Normalize(groupId, nameof(groupId)); + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", safe); + } + + private async Task WriteMarkdownBackupAsync(string groupId, HeavyMemoryEntry entry, CancellationToken ct) + { + var dir = GetHeavyMemoryBackupDir(groupId); + var file = Path.Combine(dir, $"{entry.StartAt:yyyyMMdd-HHmmss}.{Sanitize(entry.Topic)}.{entry.Id[..8]}.md"); + var md = $$""" +# 重记忆备份:{{entry.Topic}} + +- id:{{entry.Id}} +- 群:{{entry.GroupId}} +- 时间节点:{{entry.StartAt:yyyy-MM-dd HH:mm:ss zzz}} ~ {{entry.EndAt:yyyy-MM-dd HH:mm:ss zzz}} +- 参与者:{{string.Join("、", entry.Participants)}} +- 关键词:{{string.Join("、", entry.Keywords)}} +- 重要度:{{entry.Importance}} + +## 模型总结 +{{entry.Summary}} + +## 原始对话 +```text +{{entry.RawDialogue}} +``` +"""; + await File.WriteAllTextAsync(file, md, ct); + } + + private static HeavyMemoryEntry Normalize(string groupId, HeavyMemoryEntry entry) + { + entry.GroupId = groupId; + if (entry.StartAt == default) entry.StartAt = DateTimeOffset.Now; + if (entry.EndAt == default || entry.EndAt < entry.StartAt) entry.EndAt = entry.StartAt; + entry.Topic = string.IsNullOrWhiteSpace(entry.Topic) ? "未命名话题" : entry.Topic.Trim(); + entry.Summary = entry.Summary.Trim(); + entry.RawDialogue = entry.RawDialogue.Trim(); + entry.Participants = entry.Participants.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().Take(12).ToList(); + entry.Keywords = entry.Keywords.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().Take(16).ToList(); + entry.SourceMessageIds = entry.SourceMessageIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().Take(80).ToList(); + entry.CreatedAt = entry.CreatedAt == default ? DateTimeOffset.Now : entry.CreatedAt; + entry.Id = string.IsNullOrWhiteSpace(entry.Id) ? StableId(groupId, entry.StartAt, entry.EndAt, entry.Topic) : entry.Id; + return entry; + } + + private static string StableId(string groupId, DateTimeOffset start, DateTimeOffset end, string topic) + { + var input = $"{groupId}|{start:O}|{end:O}|{topic.Trim().ToLowerInvariant()}"; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))).ToLowerInvariant(); + } + + private static string? TryReadId(string line) + { + try { return JsonDocument.Parse(line).RootElement.GetProperty("id").GetString(); } + catch { return null; } + } + + private static int Score(HeavyMemoryEntry e, string query, IReadOnlyList terms) + { + var text = string.Join('\n', e.Topic, e.Summary, string.Join(' ', e.Keywords), e.RawDialogue); + var score = 0; + if (text.Contains(query, StringComparison.OrdinalIgnoreCase)) score += 12; + foreach (var term in terms) + { + if (term.Length < 2) continue; + if (e.Topic.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 7; + if (e.Keywords.Any(k => k.Contains(term, StringComparison.OrdinalIgnoreCase) || term.Contains(k, StringComparison.OrdinalIgnoreCase))) score += 5; + if (e.Summary.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3; + if (e.RawDialogue.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 1; + } + if ((DateTimeOffset.Now - e.EndAt).TotalDays < 14) score += 1; + return score; + } + + private static IEnumerable ExtractTerms(string query) + { + var cleaned = new string(query.Select(c => char.IsLetterOrDigit(c) || IsCjk(c) ? c : ' ').ToArray()); + foreach (var part in cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (part.Length >= 2) yield return part; + if (part.Any(IsCjk) && part.Length >= 4) + { + for (var i = 0; i + 2 <= part.Length; i++) yield return part.Substring(i, 2); + } + } + } + + private static bool IsCjk(char c) => c >= 0x4E00 && c <= 0x9FFF; + + private static string Trim(string text, int max) => text.Length <= max ? text : text[..max] + "\n...(重记忆片段过长,已截断)"; + + private static string Sanitize(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) value = value.Replace(c, '_'); + value = value.Trim(); + return string.IsNullOrWhiteSpace(value) ? "default" : value.Length <= 80 ? value : value[..80]; + } +} + +public sealed class HeavyMemoryEntry +{ + public string Id { get; set; } = ""; + public string GroupId { get; set; } = ""; + public string Topic { get; set; } = ""; + public string Summary { get; set; } = ""; + public DateTimeOffset StartAt { get; set; } + public DateTimeOffset EndAt { get; set; } + public List Participants { get; set; } = []; + public List Keywords { get; set; } = []; + public List SourceMessageIds { get; set; } = []; + public string RawDialogue { get; set; } = ""; + public int Importance { get; set; } = 3; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; +} diff --git a/MyAgent/Storage/LocalConfigStore.cs b/MyAgent/Storage/LocalConfigStore.cs new file mode 100644 index 0000000..3bcfa1f --- /dev/null +++ b/MyAgent/Storage/LocalConfigStore.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Core; + +namespace MyAgent.Storage; + +public sealed class LocalConfigStore(IOptions options) +{ + private readonly ShirobotOptions _defaults = options.Value; + private readonly SemaphoreSlim _lock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + private string ConfigPath => Path.Combine(AppContext.BaseDirectory, _defaults.DataDir, "config.local.json"); + + public async Task ReadAsync(CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + RuntimeConfig? local = null; + if (File.Exists(ConfigPath)) + { + var json = await File.ReadAllTextAsync(ConfigPath, ct); + if (!string.IsNullOrWhiteSpace(json)) + local = JsonSerializer.Deserialize(json, JsonOptions); + } + + local ??= new RuntimeConfig(); + ApplyDefaults(local); + return local; + } + finally + { + _lock.Release(); + } + } + + public async Task WriteAsync(RuntimeConfig config, CancellationToken ct = default) + { + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + var existing = await ReadExistingUnlockedAsync(ct) ?? new RuntimeConfig(); + ApplyDefaults(existing); + MergeProtectedFields(config, existing); + ApplyDefaults(config); + BackupExistingUnlocked(); + await File.WriteAllTextAsync(ConfigPath, JsonSerializer.Serialize(config, JsonOptions), ct); + return config; + } + finally + { + _lock.Release(); + } + } + + public string GetConfigPath() => ConfigPath; + + private async Task ReadExistingUnlockedAsync(CancellationToken ct) + { + if (!File.Exists(ConfigPath)) return null; + var json = await File.ReadAllTextAsync(ConfigPath, ct); + return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json, JsonOptions); + } + + private void BackupExistingUnlocked() + { + if (!File.Exists(ConfigPath)) return; + var bak = ConfigPath + ".bak"; + File.Copy(ConfigPath, bak, overwrite: true); + var timestampBak = Path.Combine(Path.GetDirectoryName(ConfigPath)!, $"config.local.{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json.bak"); + File.Copy(ConfigPath, timestampBak, overwrite: true); + } + + private static void MergeProtectedFields(RuntimeConfig incoming, RuntimeConfig existing) + { + // Password/API-key fields: an empty frontend password box must not erase a configured secret. + if (string.IsNullOrWhiteSpace(incoming.OpenAi.ApiKey)) incoming.OpenAi.ApiKey = existing.OpenAi.ApiKey; + if (string.IsNullOrWhiteSpace(incoming.Exa.ApiKey)) incoming.Exa.ApiKey = existing.Exa.ApiKey; + if (string.IsNullOrWhiteSpace(incoming.Search.TavilyApiKey)) incoming.Search.TavilyApiKey = existing.Search.TavilyApiKey; + if (string.IsNullOrWhiteSpace(incoming.Media.BraveApiKey)) incoming.Media.BraveApiKey = existing.Media.BraveApiKey; + + // If an older/stale frontend posts default OpenAI/Exa values with empty keys, keep the working custom values. + var defaultOpenAi = new RuntimeOpenAiConfig(); + if (incoming.OpenAi.BaseUrl == defaultOpenAi.BaseUrl && existing.OpenAi.BaseUrl != defaultOpenAi.BaseUrl) + { + incoming.OpenAi.BaseUrl = existing.OpenAi.BaseUrl; + incoming.OpenAi.Model = existing.OpenAi.Model; + } + if (incoming.Exa.MaxResults == new RuntimeExaConfig().MaxResults && existing.Exa.MaxResults > 0 && existing.Exa.MaxResults != incoming.Exa.MaxResults) + incoming.Exa.MaxResults = existing.Exa.MaxResults; + if (string.IsNullOrWhiteSpace(incoming.Exa.Mode)) incoming.Exa.Mode = existing.Exa.Mode; + + if (string.IsNullOrWhiteSpace(incoming.Bot.BotDisplayName)) incoming.Bot.BotDisplayName = existing.Bot.BotDisplayName; + if (incoming.Bot.BotAliases.Count == 0) incoming.Bot.BotAliases = existing.Bot.BotAliases; + if (incoming.Bot.BotUserId <= 0) incoming.Bot.BotUserId = existing.Bot.BotUserId; + if (incoming.Bot.DefaultGroupId <= 0) incoming.Bot.DefaultGroupId = existing.Bot.DefaultGroupId; + if (incoming.Bot.AllowedGroupIds.Count == 0) incoming.Bot.AllowedGroupIds = existing.Bot.AllowedGroupIds; + } + + private void ApplyDefaults(RuntimeConfig cfg) + { + cfg.OpenAi.BaseUrl = FirstNonEmpty(cfg.OpenAi.BaseUrl, Environment.GetEnvironmentVariable("OPENAI_BASE_URL"), _defaults.OpenAi.BaseUrl); + cfg.OpenAi.Model = FirstNonEmpty(cfg.OpenAi.Model, Environment.GetEnvironmentVariable("OPENAI_MODEL"), _defaults.OpenAi.Model); + cfg.OpenAi.RouterModel = FirstNonEmpty(cfg.OpenAi.RouterModel, Environment.GetEnvironmentVariable("OPENAI_ROUTER_MODEL"), cfg.OpenAi.Model); + cfg.OpenAi.ToolModel = FirstNonEmpty(cfg.OpenAi.ToolModel, Environment.GetEnvironmentVariable("OPENAI_TOOL_MODEL"), cfg.OpenAi.RouterModel, cfg.OpenAi.Model); + cfg.OpenAi.MemoryModel = FirstNonEmpty(cfg.OpenAi.MemoryModel, Environment.GetEnvironmentVariable("OPENAI_MEMORY_MODEL"), cfg.OpenAi.RouterModel, cfg.OpenAi.Model); + cfg.OpenAi.ReplyModel = FirstNonEmpty(cfg.OpenAi.ReplyModel, Environment.GetEnvironmentVariable("OPENAI_REPLY_MODEL"), cfg.OpenAi.Model); + cfg.OpenAi.EmbeddingModel = FirstNonEmpty(cfg.OpenAi.EmbeddingModel, Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_MODEL"), ""); + cfg.OpenAi.ApiKey = FirstNonEmpty(cfg.OpenAi.ApiKey, Environment.GetEnvironmentVariable("OPENAI_API_KEY"), _defaults.OpenAi.ApiKey, ""); + cfg.Exa.ApiKey = FirstNonEmpty(cfg.Exa.ApiKey, Environment.GetEnvironmentVariable("EXA_API_KEY"), _defaults.Exa.ApiKey, ""); + cfg.Search.WebSearchProvider = FirstNonEmpty(cfg.Search.WebSearchProvider, Environment.GetEnvironmentVariable("WEB_SEARCH_PROVIDER"), "combined"); + cfg.Search.TavilyApiKey = FirstNonEmpty(cfg.Search.TavilyApiKey, Environment.GetEnvironmentVariable("TAVILY_API_KEY"), ""); + cfg.Media.ImageSearchProvider = FirstNonEmpty(cfg.Media.ImageSearchProvider, Environment.GetEnvironmentVariable("IMAGE_SEARCH_PROVIDER"), "exa-page-image"); + cfg.Media.BraveApiKey = FirstNonEmpty(cfg.Media.BraveApiKey, Environment.GetEnvironmentVariable("BRAVE_SEARCH_API_KEY"), Environment.GetEnvironmentVariable("BRAVE_API_KEY"), ""); + if (cfg.Exa.MaxResults <= 0) cfg.Exa.MaxResults = _defaults.Exa.MaxResults; + cfg.Exa.Mode = FirstNonEmpty(cfg.Exa.Mode, _defaults.Exa.Mode, "http"); + cfg.Bot.BotDisplayName = FirstNonEmpty(cfg.Bot.BotDisplayName, Environment.GetEnvironmentVariable("BOT_DISPLAY_NAME"), "莲"); + if (cfg.Bot.BotAliases.Count == 0) cfg.Bot.BotAliases = ["Shiro", "小白"]; + cfg.Bot.BotAliases = cfg.Bot.BotAliases.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + cfg.Bot.AllowedGroupIds = cfg.Bot.AllowedGroupIds + .Concat(ParseLongList(Environment.GetEnvironmentVariable("AGENT_GROUP_IDS"))) + .Where(x => x > 0) + .Distinct() + .ToList(); + } + + private static string FirstNonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? ""; + + private static IEnumerable ParseLongList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) yield break; + foreach (var part in value.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) yield return id; + } + +} + +public sealed class RuntimeConfig +{ + public RuntimeOpenAiConfig OpenAi { get; set; } = new(); + public RuntimeExaConfig Exa { get; set; } = new(); + public RuntimeSearchConfig Search { get; set; } = new(); + public RuntimeMediaConfig Media { get; set; } = new(); + public RuntimeBotConfig Bot { get; set; } = new(); +} + +public sealed class RuntimeOpenAiConfig +{ + public string BaseUrl { get; set; } = "https://api.openai.com/v1"; + public string Model { get; set; } = "gpt-4o-mini"; + public string RouterModel { get; set; } = ""; + public string ToolModel { get; set; } = ""; + public string MemoryModel { get; set; } = ""; + public string ReplyModel { get; set; } = ""; + public string EmbeddingModel { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} + +public sealed class RuntimeExaConfig +{ + public bool Enabled { get; set; } = true; + public int MaxResults { get; set; } = 5; + public string Mode { get; set; } = "http"; // http | mcp + public string ApiKey { get; set; } = ""; +} + +public sealed class RuntimeSearchConfig +{ + public string WebSearchProvider { get; set; } = "combined"; // exa | tavily | combined + public string TavilyApiKey { get; set; } = ""; +} + +public sealed class RuntimeMediaConfig +{ + public string ImageSearchProvider { get; set; } = "exa-page-image"; // exa-page-image | brave-image-search + public string BraveApiKey { get; set; } = ""; +} + +public sealed class RuntimeBotConfig +{ + public long BotUserId { get; set; } + public string BotDisplayName { get; set; } = "莲"; + public List BotAliases { get; set; } = []; + public bool ReplyOnlyWhenMentioned { get; set; } = true; + public long DefaultGroupId { get; set; } + public List AllowedGroupIds { get; set; } = []; +} + +public sealed class OpenAiModelsRequest +{ + public string BaseUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} diff --git a/MyAgent/Storage/MemberMemoryInfo.cs b/MyAgent/Storage/MemberMemoryInfo.cs new file mode 100644 index 0000000..7b96316 --- /dev/null +++ b/MyAgent/Storage/MemberMemoryInfo.cs @@ -0,0 +1,10 @@ +namespace MyAgent.Storage; + +public sealed class MemberMemoryInfo +{ + public string UserId { get; set; } = ""; + public string Path { get; set; } = ""; + public DateTimeOffset UpdatedAt { get; set; } + public long Bytes { get; set; } + public string Preview { get; set; } = ""; +} diff --git a/MyAgent/Storage/RecentLogStore.cs b/MyAgent/Storage/RecentLogStore.cs new file mode 100644 index 0000000..d08c182 --- /dev/null +++ b/MyAgent/Storage/RecentLogStore.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Core; + +namespace MyAgent.Storage; + +public sealed class RecentLogStore(IOptions options) +{ + private readonly ShirobotOptions _options = options.Value; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly SemaphoreSlim _lock = new(1, 1); + + public string GetRecentPath(string groupId) + { + var safe = SafePathSegment.Normalize(groupId, nameof(groupId)); + return Path.Combine(AppContext.BaseDirectory, _options.DataDir, "groups", safe, "recent.jsonl"); + } + + public async Task AppendAsync(IncomingMessage message, CancellationToken ct = default) + { + var path = GetRecentPath(message.GroupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var line = JsonSerializer.Serialize(message, JsonOptions) + Environment.NewLine; + await _lock.WaitAsync(ct); + try + { + await File.AppendAllTextAsync(path, line, ct); + } + finally + { + _lock.Release(); + } + } + + public async Task AppendAssistantAsync(string groupId, string userId, string text, CancellationToken ct = default) + { + await AppendAsync(new IncomingMessage + { + GroupId = groupId, + UserId = "assistant:shiro", + Text = text, + IsMentioned = false, + CreatedAt = DateTimeOffset.Now + }, ct); + } + + public async Task AppendMissingAsync(string groupId, IReadOnlyList messages, CancellationToken ct = default) + { + if (messages.Count == 0) return 0; + var path = GetRecentPath(groupId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await _lock.WaitAsync(ct); + try + { + var existing = File.Exists(path) ? await ReadAllUnlockedAsync(path, ct) : []; + var seenIds = existing.Select(m => m.MessageId).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var seenFallback = existing.Select(FallbackKey).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = messages + .Where(m => !seenIds.Contains(m.MessageId) && !seenFallback.Contains(FallbackKey(m))) + .OrderBy(m => m.CreatedAt) + .ToList(); + if (missing.Count == 0) return 0; + + var combined = existing.Concat(missing).OrderBy(m => m.CreatedAt).ToList(); + var lines = combined.Select(m => JsonSerializer.Serialize(m, JsonOptions)); + await File.WriteAllTextAsync(path, string.Join(Environment.NewLine, lines) + Environment.NewLine, ct); + return missing.Count; + } + finally + { + _lock.Release(); + } + } + + public async Task> ReadRecentAsync(string groupId, int limit, CancellationToken ct = default) + { + var messages = await ReadAllAsync(groupId, ct); + return messages.TakeLast(Math.Max(1, limit)).ToList(); + } + + public async Task FindByMessageIdAsync(string groupId, string messageId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(messageId)) return null; + var messages = await ReadAllAsync(groupId, ct); + return messages.LastOrDefault(m => string.Equals(m.MessageId, messageId, StringComparison.OrdinalIgnoreCase)); + } + + public async Task RemoveByMessageIdAsync(string groupId, string messageId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(messageId)) return null; + var path = GetRecentPath(groupId); + if (!File.Exists(path)) return null; + + await _lock.WaitAsync(ct); + try + { + var lines = await File.ReadAllLinesAsync(path, ct); + IncomingMessage? removed = null; + var kept = new List(lines.Length); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + IncomingMessage? message = null; + try { message = JsonSerializer.Deserialize(line, JsonOptions); } catch { } + if (message is not null && string.Equals(message.MessageId, messageId, StringComparison.OrdinalIgnoreCase)) + { + removed ??= message; + continue; + } + kept.Add(line); + } + + if (removed is null) return null; + await File.WriteAllLinesAsync(path, kept, ct); + return removed; + } + finally + { + _lock.Release(); + } + } + + public async Task FindPreviousBeforeAsync(string groupId, DateTimeOffset before, IReadOnlySet excludeMessageIds, CancellationToken ct = default) + { + var messages = await ReadAllAsync(groupId, ct); + return messages + .Where(m => m.CreatedAt <= before && !excludeMessageIds.Contains(m.MessageId)) + .LastOrDefault(); + } + + public async Task> ReadBeforeAsync(string groupId, DateTimeOffset before, int limit, IReadOnlySet? excludeMessageIds = null, CancellationToken ct = default) + { + var messages = await ReadAllAsync(groupId, ct); + excludeMessageIds ??= new HashSet(StringComparer.OrdinalIgnoreCase); + return messages + .Where(m => m.CreatedAt <= before && !excludeMessageIds.Contains(m.MessageId)) + .TakeLast(Math.Max(1, limit)) + .ToList(); + } + + private async Task> ReadAllAsync(string groupId, CancellationToken ct = default) + { + var path = GetRecentPath(groupId); + if (!File.Exists(path)) return []; + + return await ReadAllUnlockedAsync(path, ct); + } + + private static async Task> ReadAllUnlockedAsync(string path, CancellationToken ct = default) + { + var lines = await File.ReadAllLinesAsync(path, ct); + return lines + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(line => + { + try { return JsonSerializer.Deserialize(line, JsonOptions); } + catch { return null; } + }) + .Where(x => x is not null) + .Cast() + .ToList(); + } + + private static string FallbackKey(IncomingMessage m) => $"{m.UserId}:{m.CreatedAt.ToUnixTimeSeconds()}:{NormalizeText(m.Text)}"; + + private static string NormalizeText(string text) + { + text = (text ?? "").Replace("\r\n", "\n").Trim(); + return text.Length <= 120 ? text : text[..120]; + } + + public async Task ClearAsync(string groupId, CancellationToken ct = default) + { + var path = GetRecentPath(groupId); + if (!File.Exists(path)) return false; + await _lock.WaitAsync(ct); + try + { + if (!File.Exists(path)) return false; + File.Delete(path); + return true; + } + finally + { + _lock.Release(); + } + } + + public async Task CountAsync(string groupId, CancellationToken ct = default) + { + var path = GetRecentPath(groupId); + if (!File.Exists(path)) return 0; + var lines = await File.ReadAllLinesAsync(path, ct); + return lines.Length; + } + + private static string Sanitize(string value) + { + foreach (var c in Path.GetInvalidFileNameChars()) + value = value.Replace(c, '_'); + return string.IsNullOrWhiteSpace(value) ? "default" : value; + } +} diff --git a/MyAgent/Storage/SafePathSegment.cs b/MyAgent/Storage/SafePathSegment.cs new file mode 100644 index 0000000..b1d47e2 --- /dev/null +++ b/MyAgent/Storage/SafePathSegment.cs @@ -0,0 +1,17 @@ +using System.Text.RegularExpressions; + +namespace MyAgent.Storage; + +internal static partial class SafePathSegment +{ + public static string Normalize(string value, string fieldName) + { + var trimmed = value?.Trim() ?? ""; + if (trimmed.Length is < 1 or > 128 || !AllowedPattern().IsMatch(trimmed)) + throw new ArgumentException($"Invalid {fieldName}.", fieldName); + return trimmed; + } + + [GeneratedRegex("^[A-Za-z0-9_-]+$", RegexOptions.CultureInvariant)] + private static partial Regex AllowedPattern(); +} diff --git a/MyAgent/Storage/WebResultCache.cs b/MyAgent/Storage/WebResultCache.cs new file mode 100644 index 0000000..3b044d6 --- /dev/null +++ b/MyAgent/Storage/WebResultCache.cs @@ -0,0 +1,192 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Core; + +namespace MyAgent.Storage; + +public enum WebResultCacheKind +{ + Search, + Fetch +} + +public sealed class WebResultCache(IOptions options, ILogger logger) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly ILogger _logger = logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public async Task TryReadSearchAsync(string query, int maxResults, CancellationToken ct = default) + { + if (!_options.WebCache.Enabled) return null; + var entry = await TryReadEntryAsync(WebResultCacheKind.Search, BuildSearchKey(query, maxResults), ct); + if (entry?.SearchResult is null) return null; + + var result = Clone(entry.SearchResult); + result.FromCache = true; + foreach (var item in result.Items) item.FromCache = true; + return result; + } + + public Task WriteSearchAsync(string query, int maxResults, WebSearchResult result, CancellationToken ct = default) + { + if (!_options.WebCache.Enabled || result.Items.Count == 0 || !string.IsNullOrWhiteSpace(result.Error)) return Task.CompletedTask; + var copy = Clone(result); + copy.FromCache = false; + foreach (var item in copy.Items) + { + item.FromCache = false; + item.Text = Limit(item.Text, _options.WebCache.MaxEntryTextChars); + } + + return WriteEntryAsync(WebResultCacheKind.Search, BuildSearchKey(query, maxResults), new WebCacheEntry + { + Kind = WebResultCacheKind.Search, + SearchResult = copy, + CreatedAt = DateTimeOffset.Now, + ExpiresAt = DateTimeOffset.Now.AddMinutes(Math.Max(1, _options.WebCache.SearchTtlMinutes)) + }, ct); + } + + public async Task TryReadFetchAsync(string url, CancellationToken ct = default) + { + if (!_options.WebCache.Enabled) return null; + var entry = await TryReadEntryAsync(WebResultCacheKind.Fetch, BuildFetchKey(url), ct); + if (entry?.FetchItem is null) return null; + + var item = Clone(entry.FetchItem); + item.FromCache = true; + return item; + } + + public Task WriteFetchAsync(string url, WebSearchItem item, CancellationToken ct = default) + { + if (!_options.WebCache.Enabled || string.IsNullOrWhiteSpace(item.Text)) return Task.CompletedTask; + var copy = Clone(item); + copy.FromCache = false; + copy.Text = Limit(copy.Text, _options.WebCache.MaxEntryTextChars); + + return WriteEntryAsync(WebResultCacheKind.Fetch, BuildFetchKey(url), new WebCacheEntry + { + Kind = WebResultCacheKind.Fetch, + FetchItem = copy, + CreatedAt = DateTimeOffset.Now, + ExpiresAt = DateTimeOffset.Now.AddMinutes(Math.Max(1, _options.WebCache.FetchTtlMinutes)) + }, ct); + } + + private async Task TryReadEntryAsync(WebResultCacheKind kind, string key, CancellationToken ct) + { + var path = GetEntryPath(kind, key); + await _lock.WaitAsync(ct); + try + { + if (!File.Exists(path)) return null; + var text = await File.ReadAllTextAsync(path, ct); + var entry = JsonSerializer.Deserialize(text, JsonOptions); + if (entry is null || entry.ExpiresAt <= DateTimeOffset.Now) + { + TryDelete(path); + return null; + } + return entry; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Web result cache read failed: {Path}", path); + return null; + } + finally + { + _lock.Release(); + } + } + + private async Task WriteEntryAsync(WebResultCacheKind kind, string key, WebCacheEntry entry, CancellationToken ct) + { + var path = GetEntryPath(kind, key); + entry.KeyHash = HashKey(key); + await _lock.WaitAsync(ct); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(entry, JsonOptions), ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Web result cache write failed: {Path}", path); + } + finally + { + _lock.Release(); + } + } + + private string GetEntryPath(WebResultCacheKind kind, string key) + { + // Security boundary: callers never provide a file path. Cache reads are restricted to this + // process-owned directory and addressed only by a SHA-256 key derived from URL/query. + var root = Path.Combine(AppContext.BaseDirectory, _options.DataDir, "web-cache", kind.ToString().ToLowerInvariant()); + return Path.Combine(root, HashKey(key) + ".json"); + } + + private static string BuildSearchKey(string query, int maxResults) => "search\n" + Math.Clamp(maxResults, 1, 10) + "\n" + NormalizeWhitespace(query); + + private static string BuildFetchKey(string url) => "fetch\n" + NormalizeFetchUrl(url); + + private static string NormalizeFetchUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return url.Trim(); + var builder = new UriBuilder(uri) + { + Fragment = string.Empty, + Host = uri.Host.ToLowerInvariant(), + Scheme = uri.Scheme.ToLowerInvariant() + }; + return builder.Uri.ToString(); + } + + private static string NormalizeWhitespace(string value) => string.Join(' ', (value ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static string HashKey(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static string Limit(string text, int maxChars) + { + maxChars = Math.Clamp(maxChars, 1000, 200000); + return text.Length <= maxChars ? text : text[..maxChars] + "\n...(缓存内容过长,已截断)"; + } + + private static WebSearchResult Clone(WebSearchResult result) => new() + { + Query = result.Query, + Error = result.Error, + FromCache = result.FromCache, + Items = result.Items.Select(Clone).ToList() + }; + + private static WebSearchItem Clone(WebSearchItem item) => new() + { + Title = item.Title, + Url = item.Url, + Text = item.Text, + FromCache = item.FromCache + }; + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { } + } + + private sealed class WebCacheEntry + { + public WebResultCacheKind Kind { get; set; } + public string KeyHash { get; set; } = ""; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public WebSearchResult? SearchResult { get; set; } + public WebSearchItem? FetchItem { get; set; } + } +} diff --git a/MyAgent/Tools/BraveImageSearchClient.cs b/MyAgent/Tools/BraveImageSearchClient.cs new file mode 100644 index 0000000..8d47129 --- /dev/null +++ b/MyAgent/Tools/BraveImageSearchClient.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public sealed class BraveImageSearchClient(LocalConfigStore configStore, ILogger logger) +{ + private readonly LocalConfigStore _configStore = configStore; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = new() { Timeout = Timeout.InfiniteTimeSpan }; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task SearchAsync(MediaSearchRequest request, CancellationToken ct = default) + { + var cfg = await _configStore.ReadAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.Media.BraveApiKey)) + { + return new MediaSearchResult + { + Query = request.Query, + Source = "brave-image-search", + Error = "Brave Search API key is not configured." + }; + } + + var count = Math.Clamp(request.MaxResults <= 0 ? 12 : request.MaxResults, 1, 50); + var url = "https://api.search.brave.com/res/v1/images/search?q=" + Uri.EscapeDataString(request.Query) + "&count=" + count; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Headers.Add("X-Subscription-Token", cfg.Media.BraveApiKey); + req.Headers.UserAgent.ParseAdd("ShirobotClaw/0.1"); + + try + { + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + return new MediaSearchResult { Query = request.Query, Source = "brave-image-search", Error = $"Brave HTTP {(int)resp.StatusCode}: {body}" }; + + using var doc = JsonDocument.Parse(body); + var result = new MediaSearchResult { Query = request.Query, Source = "brave-image-search" }; + if (!doc.RootElement.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array) + return result; + + foreach (var item in results.EnumerateArray()) + { + var imageUrl = GetString(item, "image", "url"); + if (string.IsNullOrWhiteSpace(imageUrl)) imageUrl = GetString(item, "thumbnail", "src"); + if (string.IsNullOrWhiteSpace(imageUrl)) continue; + + var pageUrl = GetString(item, "url"); + var title = GetString(item, "title"); + var width = GetInt(item, "properties", "width"); + var height = GetInt(item, "properties", "height"); + var score = 8; + if (width >= 480 && height >= 320) score += 2; + if (width > 0 && height > 0 && (width < 240 || height < 160)) score -= 4; + + result.Items.Add(new MediaImageCandidate + { + Title = title, + PageUrl = pageUrl, + ImageUrl = imageUrl, + Source = "brave-image-search", + Score = score, + Reason = width > 0 && height > 0 ? $"brave; {width}x{height}" : "brave" + }); + } + + result.Items = result.Items.OrderByDescending(x => x.Score).Take(count).ToList(); + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Brave image search failed"); + return new MediaSearchResult { Query = request.Query, Source = "brave-image-search", Error = ex.Message }; + } + } + + private static string GetString(JsonElement item, string name) + { + return item.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; + } + + private static string GetString(JsonElement item, string objectName, string name) + { + return item.TryGetProperty(objectName, out var obj) && obj.ValueKind == JsonValueKind.Object + ? GetString(obj, name) + : ""; + } + + private static int GetInt(JsonElement item, string objectName, string name) + { + if (!item.TryGetProperty(objectName, out var obj) || obj.ValueKind != JsonValueKind.Object) return 0; + if (!obj.TryGetProperty(name, out var v)) return 0; + if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)) return n; + if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out n)) return n; + return 0; + } +} diff --git a/MyAgent/Tools/CombinedWebSearchClient.cs b/MyAgent/Tools/CombinedWebSearchClient.cs new file mode 100644 index 0000000..0dec20e --- /dev/null +++ b/MyAgent/Tools/CombinedWebSearchClient.cs @@ -0,0 +1,127 @@ +using System.Diagnostics; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public sealed class CombinedWebSearchClient(ExaSearchClient exa, TavilySearchClient tavily, LocalConfigStore localConfig, WebResultCache cache, ILogger logger) : IWebSearchClient +{ + private readonly ExaSearchClient _exa = exa; + private readonly TavilySearchClient _tavily = tavily; + private readonly LocalConfigStore _localConfig = localConfig; + private readonly WebResultCache _cache = cache; + private readonly ILogger _logger = logger; + private static readonly TimeSpan ProviderTimeout = TimeSpan.FromSeconds(8); + + public async Task SearchAsync(string query, int maxResults, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + var provider = cfg.Search.WebSearchProvider; + var limit = Math.Clamp(maxResults, 1, 10); + + var cached = await _cache.TryReadSearchAsync(query, limit, ct); + if (cached is not null) return cached; + + WebSearchResult result; + if (string.Equals(provider, "tavily", StringComparison.OrdinalIgnoreCase)) + result = await _tavily.SearchAsync(query, limit, ct); + else if (string.Equals(provider, "exa", StringComparison.OrdinalIgnoreCase)) + result = await _exa.SearchAsync(query, limit, ct); + else + result = await SearchBothAsync(query, limit, ct); + + await _cache.WriteSearchAsync(query, limit, result, ct); + return result; + } + + private async Task SearchBothAsync(string query, int maxResults, CancellationToken ct) + { + // Exa + Tavily 综合模式:两个搜索源真正并发启动,互不等待。 + // 任何一个源慢/失败不会拖死整体,最多等 ProviderTimeout,然后用已完成的结果综合回答。 + var each = Math.Clamp(maxResults, 2, 10); + var totalWatch = Stopwatch.StartNew(); + var exaTask = RunProviderAsync("Exa", token => _exa.SearchAsync(query, each, token), query, ct); + var tavilyTask = RunProviderAsync("Tavily", token => _tavily.SearchAsync(query, each, token), query, ct); + + var completed = await Task.WhenAll(exaTask, tavilyTask); + totalWatch.Stop(); + + var exa = completed.First(x => x.Name == "Exa"); + var tavily = completed.First(x => x.Name == "Tavily"); + + var result = new WebSearchResult { Query = query }; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + AddInterleavedItems(result, seen, exa.Result.Items, tavily.Result.Items, maxResults); + + var notes = new List + { + $"combined_async total={totalWatch.ElapsedMilliseconds}ms", + $"Exa {exa.Status} {exa.ElapsedMs}ms items={exa.Result.Items.Count}", + $"Tavily {tavily.Status} {tavily.ElapsedMs}ms items={tavily.Result.Items.Count}" + }; + var errors = new[] { exa.Result.Error, tavily.Result.Error }.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (errors.Count > 0) notes.AddRange(errors.Select(e => "error=" + e)); + + // Error means the combined search is unusable. Provider diagnostics must not be placed + // in Error when we still have usable items, otherwise callers treat a successful partial + // search as failure and skip web/image processing. + result.Error = result.Items.Count == 0 ? string.Join("; ", notes) : null; + return result; + } + + private async Task RunProviderAsync(string name, Func> search, string query, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + try + { + var result = await search(timeout.Token); + sw.Stop(); + return new ProviderSearchResult(name, string.IsNullOrWhiteSpace(result.Error) ? "ok" : "partial", sw.ElapsedMilliseconds, result); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + sw.Stop(); + return new ProviderSearchResult(name, "timeout", sw.ElapsedMilliseconds, new WebSearchResult { Query = query, Error = $"{name} timeout after {(int)ProviderTimeout.TotalSeconds}s" }); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + sw.Stop(); + _logger.LogDebug(ex, "{Provider} search failed", name); + return new ProviderSearchResult(name, "error", sw.ElapsedMilliseconds, new WebSearchResult { Query = query, Error = $"{name} failed: {ex.Message}" }); + } + } + + private static void AddInterleavedItems(WebSearchResult result, HashSet seen, IReadOnlyList exaItems, IReadOnlyList tavilyItems, int maxResults) + { + var max = Math.Max(exaItems.Count, tavilyItems.Count); + for (var i = 0; i < max && result.Items.Count < maxResults; i++) + { + if (i < exaItems.Count) AddOne(result, seen, exaItems[i], "Exa", maxResults); + if (result.Items.Count >= maxResults) break; + if (i < tavilyItems.Count) AddOne(result, seen, tavilyItems[i], "Tavily", maxResults); + } + } + + private static void AddOne(WebSearchResult result, HashSet seen, WebSearchItem item, string source, int maxResults) + { + if (result.Items.Count >= maxResults) return; + var key = string.IsNullOrWhiteSpace(item.Url) ? item.Title : NormalizeUrl(item.Url); + if (!seen.Add(key)) return; + result.Items.Add(new WebSearchItem + { + Title = string.IsNullOrWhiteSpace(item.Title) ? $"[{source}] result" : $"[{source}] {item.Title}", + Url = item.Url, + Text = item.Text + }); + } + + private static string NormalizeUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return url.Trim(); + return uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + } + + private sealed record ProviderSearchResult(string Name, string Status, long ElapsedMs, WebSearchResult Result); +} diff --git a/MyAgent/Tools/DocumentTextExtractor.cs b/MyAgent/Tools/DocumentTextExtractor.cs new file mode 100644 index 0000000..07b507f --- /dev/null +++ b/MyAgent/Tools/DocumentTextExtractor.cs @@ -0,0 +1,117 @@ +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using UglyToad.PdfPig; + +namespace MyAgent.Tools; + +public sealed class DocumentTextExtractor(ILogger logger) +{ + private readonly ILogger _logger = logger; + private const int MaxChars = 24000; + + public async Task TryExtractAsync(string localPath, string title, string mime, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(localPath) || !File.Exists(localPath)) return ""; + var ext = Path.GetExtension(title); + if (string.IsNullOrWhiteSpace(ext)) ext = Path.GetExtension(localPath); + ext = ext.ToLowerInvariant(); + + try + { + if (IsTextLike(title, ext, mime)) + return Limit(await ReadTextAsync(localPath, ct)); + + if (ext == ".docx") return Limit(ExtractDocx(localPath)); + + if (ext == ".pdf" || mime.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)) + return Limit(ExtractPdf(localPath)); + + return ""; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Document extraction failed for {Path}", localPath); + return ""; + } + } + + private static bool IsTextLike(string title, string ext, string mime) + { + if (mime.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) return true; + if (mime.Contains("json", StringComparison.OrdinalIgnoreCase) || mime.Contains("xml", StringComparison.OrdinalIgnoreCase) || mime.Contains("yaml", StringComparison.OrdinalIgnoreCase)) return true; + var name = Path.GetFileName(title).ToLowerInvariant(); + if (name is "dockerfile" or "makefile" or "cmakelists.txt" or ".gitignore" or ".gitattributes" or ".editorconfig") return true; + return ext is + ".txt" or ".md" or ".markdown" or ".log" or ".csv" or ".tsv" or + ".json" or ".jsonl" or ".xml" or ".yaml" or ".yml" or ".toml" or ".ini" or ".conf" or ".config" or + ".cs" or ".csproj" or ".sln" or ".props" or ".targets" or ".razor" or + ".java" or ".kt" or ".kts" or ".gradle" or ".groovy" or + ".js" or ".mjs" or ".cjs" or ".ts" or ".tsx" or ".jsx" or ".vue" or ".css" or ".scss" or ".less" or ".html" or ".htm" or + ".py" or ".go" or ".rs" or ".cpp" or ".cc" or ".cxx" or ".c" or ".h" or ".hpp" or ".hh" or + ".sh" or ".bash" or ".zsh" or ".ps1" or ".bat" or ".cmd" or + ".sql" or ".php" or ".rb" or ".swift" or ".m" or ".mm" or + ".license" or ".licence"; + } + + private static async Task ReadTextAsync(string path, CancellationToken ct) + { + var bytes = await File.ReadAllBytesAsync(path, ct); + if (bytes.Any(b => b == 0)) return ""; + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2); + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2); + return Encoding.UTF8.GetString(bytes); + } + + private static string ExtractDocx(string path) + { + using var zip = ZipFile.OpenRead(path); + var entry = zip.GetEntry("word/document.xml"); + if (entry is null) return ""; + using var stream = entry.Open(); + var doc = XDocument.Load(stream); + XNamespace w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + var paragraphs = doc.Descendants(w + "p") + .Select(p => string.Concat(p.Descendants(w + "t").Select(t => t.Value)).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)); + return string.Join("\n", paragraphs); + } + + private static string ExtractPdf(string path) + { + var sb = new StringBuilder(); + using var pdf = PdfDocument.Open(path); + var pageCount = pdf.NumberOfPages; + foreach (var page in pdf.GetPages()) + { + if (sb.Length >= MaxChars) break; + var text = NormalizePdfPageText(page.Text); + if (string.IsNullOrWhiteSpace(text)) continue; + sb.AppendLine($"--- PDF 第 {page.Number}/{pageCount} 页 ---"); + sb.AppendLine(text); + sb.AppendLine(); + } + + if (sb.Length == 0) + return "PDF 文件已缓存,但没有抽取到可读文本;可能是扫描版/图片型 PDF,需要 OCR 或截图分析。"; + + return sb.ToString(); + } + + private static string NormalizePdfPageText(string text) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + text = text.Replace("\r\n", "\n").Replace('\r', '\n'); + text = Regex.Replace(text, "[ \t]{2,}", " "); + text = Regex.Replace(text, "\n{3,}", "\n\n"); + return text.Trim(); + } + + private static string Limit(string text) + { + text = Regex.Replace(text.Replace("\r\n", "\n"), "\n{3,}", "\n\n").Trim(); + return text.Length <= MaxChars ? text : text[..MaxChars] + "\n...(文件内容过长,后续已截断)"; + } +} diff --git a/MyAgent/Tools/ExaSearchClient.cs b/MyAgent/Tools/ExaSearchClient.cs new file mode 100644 index 0000000..f37303e --- /dev/null +++ b/MyAgent/Tools/ExaSearchClient.cs @@ -0,0 +1,107 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public interface IWebSearchClient +{ + Task SearchAsync(string query, int maxResults, CancellationToken ct = default); +} + +public sealed class ExaSearchClient(IOptions options, LocalConfigStore localConfig, McpExaSearchClient mcpExa, ILogger logger) : IWebSearchClient +{ + private readonly ShirobotOptions _options = options.Value; + private readonly LocalConfigStore _localConfig = localConfig; + private readonly McpExaSearchClient _mcpExa = mcpExa; + private readonly HttpClient _http = new(); + private readonly ILogger _logger = logger; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task SearchAsync(string query, int maxResults, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + return await SearchHttpAsync(query, maxResults, cfg.Exa.ApiKey, ct); + } + + public async Task SearchHttpAsync(string query, int maxResults, string? apiKey, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + if (!cfg.Exa.Enabled) + { + return new WebSearchResult + { + Query = query, + Error = "Exa search disabled in local config." + }; + } + if (string.Equals(cfg.Exa.Mode, "mcp", StringComparison.OrdinalIgnoreCase)) + return await _mcpExa.SearchAsync(query, maxResults, ct); + + apiKey = string.IsNullOrWhiteSpace(apiKey) ? cfg.Exa.ApiKey : apiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + return new WebSearchResult + { + Query = query, + Error = "EXA_API_KEY is not configured. Exa MCP/server can be wired later; direct Exa Search API fallback is implemented." + }; + } + + // 这里用 Exa HTTP Search API 做轻量实现;生产上可替换为 Exa MCP stdio/remote tool gateway。 + var payload = new + { + query, + numResults = Math.Clamp(maxResults, 1, 10), + contents = new { text = true, summary = true } + }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.exa.ai/search"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + req.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + try + { + using var resp = await _http.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + return new WebSearchResult { Query = query, Error = $"Exa HTTP {(int)resp.StatusCode}: {body}" }; + + using var doc = JsonDocument.Parse(body); + var result = new WebSearchResult { Query = query }; + if (doc.RootElement.TryGetProperty("results", out var results)) + { + foreach (var item in results.EnumerateArray().Take(maxResults)) + { + result.Items.Add(new WebSearchItem + { + Title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "", + Url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "", + Text = ExtractText(item) + }); + } + } + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Exa search failed"); + return new WebSearchResult { Query = query, Error = ex.Message }; + } + } + + private static string ExtractText(JsonElement item) + { + if (item.TryGetProperty("summary", out var s) && s.ValueKind == JsonValueKind.String) + return s.GetString() ?? ""; + if (item.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String) + { + var text = t.GetString() ?? ""; + return text.Length > 800 ? text[..800] + "..." : text; + } + return ""; + } +} diff --git a/MyAgent/Tools/GroupProfileModels.cs b/MyAgent/Tools/GroupProfileModels.cs new file mode 100644 index 0000000..c830f6a --- /dev/null +++ b/MyAgent/Tools/GroupProfileModels.cs @@ -0,0 +1,18 @@ +namespace MyAgent.Tools; + +public sealed class GroupProfileResult +{ + public long GroupId { get; set; } + public string GroupName { get; set; } = ""; + public string AvatarUrl { get; set; } = ""; + public int MemberCount { get; set; } + public List Members { get; set; } = []; +} + +public sealed class GroupMemberInfo +{ + public long UserId { get; set; } + public string Name { get; set; } = ""; + public string Role { get; set; } = "member"; + public string AvatarUrl { get; set; } = ""; +} diff --git a/MyAgent/Tools/McpExaSearchClient.cs b/MyAgent/Tools/McpExaSearchClient.cs new file mode 100644 index 0000000..9b0d9f2 --- /dev/null +++ b/MyAgent/Tools/McpExaSearchClient.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public sealed class McpExaSearchClient(IOptions options, LocalConfigStore localConfig, ILogger logger) +{ + private readonly ShirobotOptions _options = options.Value; + private readonly LocalConfigStore _localConfig = localConfig; + private readonly ILogger _logger = logger; + + public async Task SearchAsync(string query, int maxResults, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.Exa.ApiKey)) + return new WebSearchResult { Query = query, Error = "EXA_API_KEY is empty. MCP Exa server requires it." }; + + // Exa MCP stdio: npx -y exa-mcp-server, env EXA_API_KEY=... + // Tools commonly exposed: web_search_exa, web_search_advanced_exa. + var psi = new ProcessStartInfo + { + FileName = "npx", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-y"); + psi.ArgumentList.Add("exa-mcp-server"); + psi.Environment["EXA_API_KEY"] = cfg.Exa.ApiKey; + + using var process = Process.Start(psi); + if (process is null) return new WebSearchResult { Query = query, Error = "Failed to start exa-mcp-server via npx." }; + + try + { + var readerTask = ReadJsonRpcMessagesAsync(process.StandardOutput, ct); + + await SendAsync(process, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "initialize", + ["params"] = new JsonObject + { + ["protocolVersion"] = "2024-11-05", + ["capabilities"] = new JsonObject(), + ["clientInfo"] = new JsonObject { ["name"] = "MyAgent", ["version"] = "0.1" } + } + }, ct); + + await SendAsync(process, new JsonObject { ["jsonrpc"] = "2.0", ["method"] = "notifications/initialized" }, ct); + + var toolArgs = new JsonObject + { + ["query"] = query, + ["numResults"] = Math.Clamp(maxResults, 1, 10) + }; + + await SendAsync(process, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 2, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "web_search_exa", + ["arguments"] = toolArgs + } + }, ct); + + var deadline = DateTimeOffset.Now.AddSeconds(30); + await foreach (var msg in readerTask.WithCancellation(ct)) + { + if (msg["id"]?.GetValue() == 2) + return ParseMcpToolResult(query, msg); + if (DateTimeOffset.Now > deadline) break; + } + return new WebSearchResult { Query = query, Error = "MCP Exa timeout." }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "MCP Exa search failed"); + return new WebSearchResult { Query = query, Error = ex.Message }; + } + finally + { + try { if (!process.HasExited) process.Kill(true); } catch { } + } + } + + private static async Task SendAsync(Process process, JsonObject obj, CancellationToken ct) + { + var json = obj.ToJsonString(); + var bytes = Encoding.UTF8.GetByteCount(json); + await process.StandardInput.WriteAsync($"Content-Length: {bytes}\r\n\r\n{json}".AsMemory(), ct); + await process.StandardInput.FlushAsync(ct); + } + + private static async IAsyncEnumerable ReadJsonRpcMessagesAsync(StreamReader reader, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + while (!reader.EndOfStream && !ct.IsCancellationRequested) + { + string? line; + int? contentLength = null; + while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(ct))) + { + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase) + && int.TryParse(line["Content-Length:".Length..].Trim(), out var len)) + contentLength = len; + } + if (contentLength is null) continue; + var buffer = new char[contentLength.Value]; + var read = await reader.ReadBlockAsync(buffer, 0, buffer.Length); + if (read <= 0) continue; + var json = new string(buffer, 0, read); + if (JsonNode.Parse(json) is JsonObject obj) yield return obj; + } + } + + private static WebSearchResult ParseMcpToolResult(string query, JsonObject msg) + { + var result = new WebSearchResult { Query = query }; + if (msg["error"] is JsonObject err) + { + result.Error = err.ToJsonString(); + return result; + } + + var content = msg["result"]?["content"] as JsonArray; + if (content is null) return result; + + foreach (var part in content) + { + var text = part?["text"]?.GetValue(); + if (string.IsNullOrWhiteSpace(text)) continue; + // Exa MCP 通常返回格式化文本/JSON 文本。这里先作为一条文本结果交给 LLM。 + result.Items.Add(new WebSearchItem + { + Title = "Exa MCP Result", + Url = "", + Text = text.Length > 2500 ? text[..2500] + "..." : text + }); + } + return result; + } +} diff --git a/MyAgent/Tools/MediaService.cs b/MyAgent/Tools/MediaService.cs new file mode 100644 index 0000000..510bbac --- /dev/null +++ b/MyAgent/Tools/MediaService.cs @@ -0,0 +1,508 @@ +using System.Buffers.Binary; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using MyAgent.Core; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public sealed class MediaService(IWebSearchClient webSearch, BraveImageSearchClient brave, LocalConfigStore configStore, IOptions options, ILogger logger) +{ + private readonly IWebSearchClient _webSearch = webSearch; + private readonly BraveImageSearchClient _brave = brave; + private readonly LocalConfigStore _configStore = configStore; + private readonly ShirobotOptions _options = options.Value; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = CreateHttpClient(); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + private static readonly Regex OgImageRegex = new("]+(?:property|name)=[\\\"'](?:og:image|og:image:secure_url|twitter:image|twitter:image:src)[\\\"'][^>]+content=[\\\"'](?[^\\\"']+)[\\\"'][^>]*>|]+content=[\\\"'](?[^\\\"']+)[\\\"'][^>]+(?:property|name)=[\\\"'](?:og:image|og:image:secure_url|twitter:image|twitter:image:src)[\\\"'][^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ImgRegex = new("]+(?:src|data-src|data-original)=[\\\"'](?[^\\\"']+)[\\\"'][^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex QueryTokenRegex = new(@"[a-z0-9][a-z0-9+._-]{1,}|[\u4e00-\u9fff]{2,}|[ァ-ヶー]{2,}|[ぁ-ゖー]{2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly string[] ImageExts = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"]; + + public IReadOnlyList GetSources() => + [ + new() { Id = "exa-page-image", Name = "Exa 网页搜索 + og:image/img 提取", Kind = "implemented", NeedApiKey = "EXA_API_KEY", Notes = "当前 MVP。先找真实网页 URL,再从页面提取 og:image/twitter:image/img。适合官方图、文章封面、百科图。" }, + new() { Id = "direct-url", Name = "用户直接提供图片 URL", Kind = "implemented", NeedApiKey = "none", Notes = "用于下载/缓存用户给出的真实 http(s) 图片。" }, + new() { Id = "brave-image-search", Name = "Brave Image Search API", Kind = "recommended-next", NeedApiKey = "BRAVE_API_KEY", Notes = "真正的图片搜索 API,结果通常含 image URL、thumbnail、source page。适合后续增强。" }, + new() { Id = "bing-image-search", Name = "Bing Image Search / Azure", Kind = "optional", NeedApiKey = "AZURE_BING_KEY", Notes = "成熟稳定,但需要 Azure/Bing Search 资源。" }, + new() { Id = "google-cse", Name = "Google Custom Search image mode", Kind = "optional", NeedApiKey = "GOOGLE_API_KEY + CX", Notes = "可用 searchType=image,但配置稍繁琐。" }, + new() { Id = "serpapi-images", Name = "SerpAPI / Serper 图片搜索", Kind = "optional", NeedApiKey = "SERPAPI_KEY/SERPER_KEY", Notes = "封装搜索引擎图片结果,接入简单但通常付费。" } + ]; + + public async Task SearchImagesAsync(MediaSearchRequest request, CancellationToken ct = default) + { + var query = request.Query.Trim(); + var maxPages = Math.Clamp(request.MaxPages <= 0 ? 5 : request.MaxPages, 1, 10); + var maxImages = Math.Clamp(request.MaxResults <= 0 ? 12 : request.MaxResults, 1, 30); + var result = new MediaSearchResult { Query = query, Source = "exa-page-image" }; + if (string.IsNullOrWhiteSpace(query)) + { + result.Error = "query is empty"; + return result; + } + + var cfg = await _configStore.ReadAsync(ct); + if (string.Equals(cfg.Media.ImageSearchProvider, "brave-image-search", StringComparison.OrdinalIgnoreCase)) + { + var brave = await _brave.SearchAsync(request, ct); + if (string.IsNullOrWhiteSpace(brave.Error)) return brave; + // Fallback to Exa page extraction if Brave is not configured or fails. + result.Error = "Brave 图片搜索失败,已尝试降级 Exa 页面提取:" + brave.Error; + } + + var search = await _webSearch.SearchAsync(query, maxPages, ct); + if (!string.IsNullOrWhiteSpace(search.Error)) + { + result.Error = string.IsNullOrWhiteSpace(result.Error) ? search.Error : result.Error + "; " + search.Error; + return result; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var candidates = new List(); + foreach (var page in search.Items.Take(maxPages)) + { + await foreach (var item in ExtractFromPageAsync(page, query, ct)) + { + if (item.Score < 2) continue; + if (seen.Add(item.ImageUrl)) candidates.Add(item); + } + } + + result.Items = candidates + .OrderByDescending(x => x.Score) + .ThenBy(x => SourceRank(x.Source)) + .Take(maxImages) + .ToList(); + if (result.Items.Count == 0) + result.Error = "没有找到高置信图片候选;已过滤掉 logo、图标、站点装饰图或和搜索词弱相关的图片。"; + + return result; + } + + public async Task DownloadAsync(MediaDownloadRequest request, CancellationToken ct = default) + { + if (!Uri.TryCreate(request.ImageUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return new CachedMediaResult { Error = "Only http(s) image URLs are supported." }; + + var safety = await ValidatePublicUrlAsync(uri, ct); + if (!string.IsNullOrWhiteSpace(safety)) return new CachedMediaResult { Error = safety }; + + var dir = MediaDir; + Directory.CreateDirectory(dir); + var id = Sha256Hex(uri.ToString()); + + using var req = new HttpRequestMessage(HttpMethod.Get, uri); + req.Headers.Accept.ParseAdd("image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"); + req.Headers.Referrer = ResolveImageReferer(request.PageUrl, uri); + if (IsQqMultimediaHost(uri)) + { + req.Headers.TryAddWithoutValidation("Origin", "https://im.qq.com"); + req.Headers.TryAddWithoutValidation("Sec-Fetch-Dest", "image"); + req.Headers.TryAddWithoutValidation("Sec-Fetch-Mode", "no-cors"); + req.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site"); + } + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) return new CachedMediaResult { Error = $"HTTP {(int)resp.StatusCode}" }; + + var mime = resp.Content.Headers.ContentType?.MediaType ?? GuessMime(uri.AbsolutePath); + if (!mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return new CachedMediaResult { Error = $"Not an image content-type: {mime}" }; + + var maxBytes = Math.Clamp(request.MaxBytes <= 0 ? 10 * 1024 * 1024 : request.MaxBytes, 128 * 1024, 30 * 1024 * 1024); + var ext = ExtensionFromMime(mime, uri.AbsolutePath); + var path = Path.Combine(dir, id + ext); + long total = 0; + await using (var input = await resp.Content.ReadAsStreamAsync(ct)) + await using (var output = File.Create(path)) + { + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, ct); + if (read == 0) break; + total += read; + if (total > maxBytes) + { + output.Close(); + File.Delete(path); + return new CachedMediaResult { Error = $"Image too large, limit={maxBytes} bytes" }; + } + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + } + + if (!request.SkipImageQualityFilter && total < 15 * 1024) + { + TryDelete(path); + return new CachedMediaResult { Error = "Image too small; likely logo/icon/noise." }; + } + + var dimensions = TryReadImageDimensions(path); + if (!request.SkipImageQualityFilter && dimensions is { } dim && (dim.Width < 240 || dim.Height < 160)) + { + TryDelete(path); + return new CachedMediaResult { Error = $"Image dimensions too small ({dim.Width}x{dim.Height}); likely logo/icon/noise." }; + } + + var meta = new CachedMediaResult + { + MediaId = id, + Url = $"/api/media/{id}", + LocalPath = path, + SourceUrl = uri.ToString(), + PageUrl = request.PageUrl ?? "", + Mime = mime, + Size = total + }; + await File.WriteAllTextAsync(Path.Combine(dir, id + ".json"), JsonSerializer.Serialize(meta, JsonOptions), ct); + return meta; + } + + public async Task<(string Path, string Mime)?> TryGetFileAsync(string mediaId, CancellationToken ct = default) + { + var metaPath = Path.Combine(MediaDir, mediaId + ".json"); + if (File.Exists(metaPath)) + { + var json = await File.ReadAllTextAsync(metaPath, ct); + var meta = JsonSerializer.Deserialize(json, JsonOptions); + if (meta is not null && File.Exists(meta.LocalPath)) return (meta.LocalPath, meta.Mime); + } + + foreach (var file in Directory.Exists(MediaDir) ? Directory.EnumerateFiles(MediaDir, mediaId + ".*") : []) + { + if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue; + return (file, GuessMime(file)); + } + return null; + } + + private async IAsyncEnumerable ExtractFromPageAsync(WebSearchItem page, string query, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + if (LooksLikeImageUrl(page.Url)) + { + var direct = BuildCandidate(query, page, page.Url, "search-result-url", ""); + if (direct is not null) yield return direct; + yield break; + } + + if (!Uri.TryCreate(page.Url, UriKind.Absolute, out var pageUri) || (pageUri.Scheme != Uri.UriSchemeHttp && pageUri.Scheme != Uri.UriSchemeHttps)) yield break; + var safety = await ValidatePublicUrlAsync(pageUri, ct); + if (!string.IsNullOrWhiteSpace(safety)) yield break; + + string html; + try + { + using var resp = await _http.GetAsync(pageUri, HttpCompletionOption.ResponseHeadersRead, ct); + if (!resp.IsSuccessStatusCode) yield break; + var contentType = resp.Content.Headers.ContentType?.MediaType ?? ""; + if (!contentType.Contains("html", StringComparison.OrdinalIgnoreCase) && !contentType.Contains("text", StringComparison.OrdinalIgnoreCase)) yield break; + html = await resp.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Failed to fetch image candidate page {Url}", page.Url); + yield break; + } + + foreach (Match m in OgImageRegex.Matches(html).Take(6)) + { + var url = NormalizeImageUrl(m.Groups["url"].Value, pageUri); + var candidate = url is null ? null : BuildCandidate(query, page, url, "meta-image", ""); + if (candidate is not null) yield return candidate; + } + + foreach (Match m in ImgRegex.Matches(html).Take(30)) + { + var url = NormalizeImageUrl(m.Groups["url"].Value, pageUri); + var alt = ExtractAttribute(m.Value, "alt"); + var candidate = url is null ? null : BuildCandidate(query, page, url, "img-tag", alt); + if (candidate is not null) yield return candidate; + } + } + + private static MediaImageCandidate? BuildCandidate(string query, WebSearchItem page, string imageUrl, string source, string alt) + { + if (IsLikelyDecorativeImage(imageUrl, alt)) return null; + + var score = source switch + { + "search-result-url" => 4, + "meta-image" => 3, + "img-tag" => 0, + _ => 0 + }; + + var haystacks = new[] { page.Title, page.Text, page.Url, imageUrl, alt }; + var matched = new List(); + foreach (var token in ExtractMeaningfulTokens(query)) + { + if (haystacks.Any(h => ContainsNormalized(h, token))) + { + score += token.Length >= 4 ? 2 : 1; + matched.Add(token); + } + } + + var host = TryHost(page.Url).Replace("www.", ""); + if (!string.IsNullOrWhiteSpace(host) && ContainsNormalized(imageUrl, host)) score += 1; + if (source == "img-tag" && matched.Count == 0) score -= 2; + + return new MediaImageCandidate + { + Title = page.Title, + PageUrl = page.Url, + ImageUrl = imageUrl, + Source = source, + Score = score, + Reason = matched.Count == 0 ? $"source={source}" : $"source={source}; matched={string.Join(',', matched.Distinct())}" + }; + } + + private static IEnumerable ExtractMeaningfulTokens(string query) + { + var stop = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "图片", "图", "照片", "壁纸", "头像", "封面", "同人图", "找找", "帮我", "搜索", "搜图", "官方", "相关", "的", "一下", "image", "images", "photo", "picture", "pic" + }; + foreach (Match m in QueryTokenRegex.Matches(query)) + { + var token = m.Value.Trim().Trim('-', '_', '.', '+'); + if (token.Length < 2 || stop.Contains(token)) continue; + yield return token; + } + } + + private static bool ContainsNormalized(string text, string token) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(token)) return false; + static string Norm(string s) => Regex.Replace(s.ToLowerInvariant(), @"[\s\-_+.]+", ""); + return Norm(text).Contains(Norm(token), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLikelyDecorativeImage(string imageUrl, string alt) + { + var s = (imageUrl + " " + alt).ToLowerInvariant(); + if (s.Contains("favicon") || s.Contains("/logo") || s.Contains("logo.") || s.Contains("sprite") || s.Contains("placeholder") || s.Contains("loading") || s.Contains("pixel") || s.Contains("badge") || s.Contains("avatar")) return true; + if (s.Contains("/common/images/services/") || s.Contains("apple-touch-icon") || s.Contains("social-icon")) return true; + var clean = imageUrl.Split('?', '#')[0].ToLowerInvariant(); + var file = Path.GetFileNameWithoutExtension(clean); + return file is "logo" or "icon" or "favicon" or "placeholder"; + } + + private static int SourceRank(string source) => source switch + { + "search-result-url" => 0, + "meta-image" => 1, + "img-tag" => 2, + _ => 9 + }; + + private static string ExtractAttribute(string html, string name) + { + var match = Regex.Match(html, "\\s" + Regex.Escape(name) + "\\s*=\\s*([\\\"'])(?.*?)\\1", RegexOptions.IgnoreCase | RegexOptions.Singleline); + return match.Success ? WebUtility.HtmlDecode(match.Groups["v"].Value) : ""; + } + + private static string TryHost(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : ""; + } + + private string MediaDir => Path.Combine(AppContext.BaseDirectory, _options.DataDir, "media"); + + private static HttpClient CreateHttpClient() + { + // 不在本地 HttpClient 上设置硬超时/限速;耗时控制交给外层 CancellationToken。 + // 否则慢图片源会抛 TaskCanceledException,并被误报成“模型回复失败”。 + var client = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false }) { Timeout = Timeout.InfiniteTimeSpan }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 ShirobotClaw/0.1"); + return client; + } + + private static Uri? ResolveImageReferer(string? pageUrl, Uri imageUri) + { + if (!string.IsNullOrWhiteSpace(pageUrl) + && Uri.TryCreate(pageUrl, UriKind.Absolute, out var refUri) + && (refUri.Scheme == Uri.UriSchemeHttp || refUri.Scheme == Uri.UriSchemeHttps)) + return refUri; + return IsQqMultimediaHost(imageUri) ? new Uri("https://im.qq.com/") : null; + } + + private static bool IsQqMultimediaHost(Uri uri) => + uri.Host.EndsWith("qq.com", StringComparison.OrdinalIgnoreCase) + && (uri.Host.Contains("multimedia", StringComparison.OrdinalIgnoreCase) + || uri.Host.Contains("qpic", StringComparison.OrdinalIgnoreCase) + || uri.Host.Contains("gtimg", StringComparison.OrdinalIgnoreCase)); + + private static string? NormalizeImageUrl(string value, Uri baseUri) + { + value = WebUtility.HtmlDecode(value).Trim(); + if (string.IsNullOrWhiteSpace(value) || value.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return null; + if (value.StartsWith("//")) value = baseUri.Scheme + ":" + value; + if (!Uri.TryCreate(baseUri, value, out var uri)) return null; + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return null; + return LooksLikeImageUrl(uri.ToString()) || value.Contains("image", StringComparison.OrdinalIgnoreCase) ? uri.ToString() : uri.ToString(); + } + + private static bool LooksLikeImageUrl(string url) + { + var clean = url.Split('?', '#')[0]; + return ImageExts.Any(ext => clean.EndsWith(ext, StringComparison.OrdinalIgnoreCase)); + } + + private static async Task ValidatePublicUrlAsync(Uri uri, CancellationToken ct) + { + try + { + var addresses = await Dns.GetHostAddressesAsync(uri.Host, ct); + if (addresses.Length == 0) return "Host cannot be resolved."; + if (addresses.Any(IsPrivateOrLocal)) return "Refusing to access localhost/private/link-local address."; + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return "DNS failed: " + ex.Message; + } + } + + private static bool IsPrivateOrLocal(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) return true; + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + return b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168) || b[0] == 127 || (b[0] == 169 && b[1] == 254); + } + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || IPAddress.IPv6Loopback.Equals(ip); + } + return false; + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + private static (int Width, int Height)? TryReadImageDimensions(string path) + { + try + { + var b = File.ReadAllBytes(path); + if (b.Length >= 24 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) + return (BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(16, 4)), BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(20, 4))); + if (b.Length >= 10 && b[0] == 'G' && b[1] == 'I' && b[2] == 'F') + return (BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(6, 2)), BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(8, 2))); + if (b.Length >= 12 && b[0] == 0xFF && b[1] == 0xD8) + { + var i = 2; + while (i + 9 < b.Length) + { + if (b[i] != 0xFF) { i++; continue; } + var marker = b[i + 1]; + var len = BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(i + 2, 2)); + if (len < 2 || i + 2 + len > b.Length) break; + if ((marker >= 0xC0 && marker <= 0xC3) || (marker >= 0xC5 && marker <= 0xC7) || (marker >= 0xC9 && marker <= 0xCB) || (marker >= 0xCD && marker <= 0xCF)) + return (BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(i + 7, 2)), BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(i + 5, 2))); + i += 2 + len; + } + } + } + catch { } + return null; + } + + private static string Sha256Hex(string text) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..32]; + + private static string GuessMime(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".avif" => "image/avif", + _ => "application/octet-stream" + }; + } + + private static string ExtensionFromMime(string mime, string fallbackPath) + { + var byMime = mime.ToLowerInvariant() switch + { + "image/jpeg" => ".jpg", + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "image/avif" => ".avif", + _ => "" + }; + if (!string.IsNullOrWhiteSpace(byMime)) return byMime; + var ext = Path.GetExtension(fallbackPath).ToLowerInvariant(); + return ImageExts.Contains(ext) ? ext : ".img"; + } +} + +public sealed class MediaSourceInfo +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Kind { get; set; } = ""; + public string NeedApiKey { get; set; } = ""; + public string Notes { get; set; } = ""; +} + +public sealed class MediaSearchRequest +{ + public string Query { get; set; } = ""; + public int MaxPages { get; set; } = 5; + public int MaxResults { get; set; } = 12; +} + +public sealed class MediaSearchResult +{ + public string Query { get; set; } = ""; + public string Source { get; set; } = ""; + public List Items { get; set; } = []; + public string? Error { get; set; } +} + +public sealed class MediaImageCandidate +{ + public string Title { get; set; } = ""; + public string PageUrl { get; set; } = ""; + public string ImageUrl { get; set; } = ""; + public string Source { get; set; } = ""; + public int Score { get; set; } + public string Reason { get; set; } = ""; +} + +public sealed class MediaDownloadRequest +{ + public string ImageUrl { get; set; } = ""; + public string? PageUrl { get; set; } + public string? GroupId { get; set; } + public int MaxBytes { get; set; } = 10 * 1024 * 1024; + public bool SkipImageQualityFilter { get; set; } +} + +public sealed class CachedMediaResult +{ + public string MediaId { get; set; } = ""; + public string Url { get; set; } = ""; + public string LocalPath { get; set; } = ""; + public string SourceUrl { get; set; } = ""; + public string PageUrl { get; set; } = ""; + public string Mime { get; set; } = ""; + public long Size { get; set; } + public string? Error { get; set; } +} diff --git a/MyAgent/Tools/TavilySearchClient.cs b/MyAgent/Tools/TavilySearchClient.cs new file mode 100644 index 0000000..ea4d6f9 --- /dev/null +++ b/MyAgent/Tools/TavilySearchClient.cs @@ -0,0 +1,81 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using MyAgent.Storage; + +namespace MyAgent.Tools; + +public sealed class TavilySearchClient(LocalConfigStore localConfig, ILogger logger) +{ + private readonly LocalConfigStore _localConfig = localConfig; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = new() { Timeout = Timeout.InfiniteTimeSpan }; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task SearchAsync(string query, int maxResults, CancellationToken ct = default) + { + var cfg = await _localConfig.ReadAsync(ct); + return await SearchAsync(query, maxResults, cfg.Search.TavilyApiKey, ct); + } + + public async Task SearchAsync(string query, int maxResults, string? apiKey, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(apiKey)) + return new WebSearchResult { Query = query, Error = "TAVILY_API_KEY is not configured." }; + + var payload = new + { + api_key = apiKey, + query, + search_depth = "basic", + max_results = Math.Clamp(maxResults, 1, 10), + include_answer = false, + include_raw_content = false + }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.tavily.com/search"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + req.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + try + { + using var resp = await _http.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + return new WebSearchResult { Query = query, Error = $"Tavily HTTP {(int)resp.StatusCode}: {body}" }; + + using var doc = JsonDocument.Parse(body); + var result = new WebSearchResult { Query = query }; + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var item in results.EnumerateArray().Take(maxResults)) + { + result.Items.Add(new WebSearchItem + { + Title = GetString(item, "title"), + Url = GetString(item, "url"), + Text = ExtractText(item) + }); + } + } + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Tavily search failed"); + return new WebSearchResult { Query = query, Error = ex.Message }; + } + } + + private static string ExtractText(JsonElement item) + { + var text = GetString(item, "content"); + if (string.IsNullOrWhiteSpace(text)) text = GetString(item, "raw_content"); + return text.Length > 900 ? text[..900] + "..." : text; + } + + private static string GetString(JsonElement e, string name) + { + return e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; + } +} diff --git a/MyAgent/Tools/ToolCatalog.cs b/MyAgent/Tools/ToolCatalog.cs new file mode 100644 index 0000000..8477cd3 --- /dev/null +++ b/MyAgent/Tools/ToolCatalog.cs @@ -0,0 +1,96 @@ +using System.Text; + +namespace MyAgent.Tools; + +public enum ToolExecutorKind +{ + InternalWebSearch, + InternalWebFetch, + InternalImageSearch, + Mcp, + Skill +} + +public sealed class ToolCapability +{ + public string Name { get; init; } = ""; + public ToolExecutorKind Executor { get; init; } + public string Purpose { get; init; } = ""; + public string WhenToUse { get; init; } = ""; + public string DoNotUseWhen { get; init; } = ""; + public string InputSchemaHint { get; init; } = ""; + public int DefaultMaxResults { get; init; } = 5; + public bool ReturnsEvidence { get; init; } = true; + public bool ReturnsAttachments { get; init; } +} + +/// +/// Central tool/skill capability registry. +/// PlannerAgent only sees this capability summary; ToolAgent uses the same catalog to validate and dispatch. +/// Later this can load extra MCP/skill tools from config without changing Planner prompts. +/// +public sealed class ToolCatalog +{ + private readonly IReadOnlyList _tools = + [ + new ToolCapability + { + Name = "web_fetch", + Executor = ToolExecutorKind.InternalWebFetch, + Purpose = "读取用户明确给出的 http/https URL 内容,提取正文作为证据。", + WhenToUse = "用户围绕一个明确链接提问,例如 GitHub/NuGet/官方文档/许可证页/项目主页/文章链接的总结、分析、是否可行、怎么用。", + DoNotUseWhen = "用户只是纯分享链接、没有问机器人;或 URL 不是 http/https;或需要找未知页面时应使用 web_search。", + InputSchemaHint = "{ name:'web_fetch', url:'https://...', reason:'为什么读取这个链接', maxResults:1 }", + DefaultMaxResults = 1, + ReturnsEvidence = true + }, + new ToolCapability + { + Name = "web_search", + Executor = ToolExecutorKind.InternalWebSearch, + Purpose = "搜索未知网页、当前资料、官方来源、项目状态、版本/兼容/价格/新闻/平台能力。", + WhenToUse = "问题需要最新事实、可验证来源、交叉核验,或技术/部署/CDN/许可证/安全/性能等会影响决策。", + DoNotUseWhen = "纯计算、普通闲聊、语言润色、上下文已足够;用户给了明确 URL 且只问该 URL 时优先 web_fetch。", + InputSchemaHint = "{ name:'web_search', query:'包含核心实体/年份/平台/来源类型的具体查询', reason:'为什么需要搜索', maxResults:5 }", + DefaultMaxResults = 5, + ReturnsEvidence = true + }, + new ToolCapability + { + Name = "image_search", + Executor = ToolExecutorKind.InternalImageSearch, + Purpose = "搜索并下载少量相关图片,作为 QQ 图片附件发送。", + WhenToUse = "用户明确要找图、壁纸、头像、角色图、官方图、视觉参考、表情包等。query 需要消歧。", + DoNotUseWhen = "用户是在询问当前上传的图片内容;这种情况应看附件,不要搜图。", + InputSchemaHint = "{ name:'image_search', query:'作品/角色/官方图等消歧查询', reason:'为什么需要图片', maxResults:2 }", + DefaultMaxResults = 2, + ReturnsEvidence = true, + ReturnsAttachments = true + } + ]; + + public IReadOnlyList List() => _tools; + + public bool TryGet(string name, out ToolCapability capability) + { + capability = _tools.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))!; + return capability is not null; + } + + public string FormatForPlanner() + { + var sb = new StringBuilder(); + foreach (var tool in _tools) + { + sb.AppendLine($"- {tool.Name}"); + sb.AppendLine($" purpose: {tool.Purpose}"); + sb.AppendLine($" use_when: {tool.WhenToUse}"); + sb.AppendLine($" avoid_when: {tool.DoNotUseWhen}"); + sb.AppendLine($" input: {tool.InputSchemaHint}"); + sb.AppendLine($" returns: evidence={tool.ReturnsEvidence}, attachments={tool.ReturnsAttachments}"); + } + return sb.ToString().Trim(); + } + + public string AllowedToolNamesForPrompt() => string.Join('|', _tools.Select(x => x.Name)); +} diff --git a/MyAgent/appsettings.json b/MyAgent/appsettings.json new file mode 100644 index 0000000..100601d --- /dev/null +++ b/MyAgent/appsettings.json @@ -0,0 +1,36 @@ +{ + "Shirobot": { + "DataDir": "data", + "OpenAi": { + "BaseUrl": "https://api.openai.com/v1", + "Model": "gpt-4o-mini" + }, + "Exa": { + "Enabled": true, + "MaxResults": 5 + }, + "Fusion": { + "DebounceMs": 450, + "SameUserMergeWindowSec": 8, + "MaxMergedMessages": 8 + }, + "Context": { + "RecentMessages": 30, + "MaxMarkdownChars": 6000, + "MaxAnswerChars": 1200 + }, + "Memory": { + "Enabled": true, + "CompressEveryMessages": 20, + "CompressEveryMinutes": 10, + "ContextSoftLimitChars": 12000 + }, + "Runtime": { + "PerGroupMaxLlmTasks": 2, + "PerUserPerGroupMaxTasks": 1, + "FastTimeoutMs": 15000, + "WebTimeoutMs": 25000, + "QualityTimeoutMs": 45000 + } + } +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/tests/MyAgent.Tests/Agents/PlannerIntentWeightTests.cs b/tests/MyAgent.Tests/Agents/PlannerIntentWeightTests.cs new file mode 100644 index 0000000..2bb7616 --- /dev/null +++ b/tests/MyAgent.Tests/Agents/PlannerIntentWeightTests.cs @@ -0,0 +1,85 @@ +using MyAgent.Agents; +using MyAgent.Core; +using MyAgent.Tools; +using Xunit; + +namespace MyAgent.Tests.Agents; + +public sealed class PlannerIntentWeightTests +{ + [Fact] + public void ParsePlan_HighSemanticWeightSelectsToolWithoutKeywordRules() + { + var json = """ + { + "nextAction":"NeedTool", + "taskType":"FactCheck", + "complexity":"Normal", + "toolWeight":0.91, + "memoryRecallWeight":0.08, + "analysisWeight":0.2, + "tools":[{ + "name":"web_search", + "query":"Acme Cloud service availability July 2026", + "reason":"The answer depends on current external state", + "maxResults":5, + "confidence":0.88 + }] + } + """; + + var plan = PlannerAgent.ParsePlan(json, Router(), new ToolCatalog()); + + Assert.True(plan.NeedsTool); + Assert.Single(plan.Tools); + Assert.Equal("web_search", plan.Tools[0].Name); + Assert.Equal(0.91, plan.ToolWeight, 2); + } + + [Fact] + public void ParsePlan_LowWeightsDoNotTriggerToolsOrMemoryDespiteToolWords() + { + var json = """ + { + "nextAction":"NeedTool", + "taskType":"Chat", + "complexity":"Simple", + "toolWeight":0.2, + "memoryRecallWeight":0.3, + "analysisWeight":0.1, + "reason":"The user is discussing the words search, image and previous, not requesting actions", + "tools":[{ + "name":"image_search", + "query":"wallpaper avatar image", + "confidence":0.25 + }] + } + """; + + var plan = PlannerAgent.ParsePlan(json, Router(), new ToolCatalog()); + + Assert.False(plan.NeedsTool); + Assert.False(plan.NeedsMemoryRecall); + Assert.False(plan.NeedsAnalysis); + Assert.Empty(plan.Tools); + Assert.Equal(PlannerNextAction.ReadyToReply, plan.NextAction); + } + + [Theory] + [InlineData(0.49, false)] + [InlineData(0.5, true)] + [InlineData(0.9, true)] + public void ToolThreshold_IsStable(double weight, bool expected) + { + Assert.Equal(expected, IntentWeightPolicy.ShouldUseTool(weight)); + } + + private static RouterDecisionEnvelope Router() => new() + { + ShouldReply = true, + ShouldEnter = true, + Target = ConversationTarget.Bot, + Complexity = TaskComplexity.Simple, + Route = new RouteDecision { ShouldReply = true } + }; +} diff --git a/tests/MyAgent.Tests/Core/ArtifactJournalTests.cs b/tests/MyAgent.Tests/Core/ArtifactJournalTests.cs new file mode 100644 index 0000000..57c36a8 --- /dev/null +++ b/tests/MyAgent.Tests/Core/ArtifactJournalTests.cs @@ -0,0 +1,22 @@ +using MyAgent.Core; +using Xunit; + +namespace MyAgent.Tests.Core; + +public sealed class ArtifactJournalTests +{ + [Fact] + public void AppendAndRead_PreservesTypedArtifactsAndSequence() + { + var journal = new ArtifactJournal(); + + var first = journal.Append("text", "agent-a", "one"); + var second = journal.Append("number", "agent-b", 2); + + Assert.Equal(1, first.Sequence); + Assert.Equal(2, second.Sequence); + Assert.Equal(["one"], journal.Read()); + Assert.Equal([2], journal.Read()); + Assert.Equal(2, journal.Snapshot().Count); + } +} diff --git a/tests/MyAgent.Tests/Core/ExecutionPlanCompilerTests.cs b/tests/MyAgent.Tests/Core/ExecutionPlanCompilerTests.cs new file mode 100644 index 0000000..a44cba5 --- /dev/null +++ b/tests/MyAgent.Tests/Core/ExecutionPlanCompilerTests.cs @@ -0,0 +1,34 @@ +using MyAgent.Core; +using MyAgent.Tools; +using Xunit; + +namespace MyAgent.Tests.Core; + +public sealed class ExecutionPlanCompilerTests +{ + [Fact] + public void Compile_CreatesParallelToolsAndDependentAnalyst() + { + var compiler = new ExecutionPlanCompiler(new SubagentRegistry(), new ToolCatalog()); + var decision = new PlannerDecisionEnvelope + { + NeedsTool = true, + NeedsAnalysis = true, + Route = new RouteDecision { WebPolicy = WebPolicy.Required }, + Tools = + [ + new RouteToolRequest { Name = "web_search", Query = "one" }, + new RouteToolRequest { Name = "web_fetch", Url = "https://example.com" } + ] + }; + + var plan = compiler.Compile(decision); + + Assert.Equal(3, plan.Nodes.Count); + Assert.Empty(plan.Nodes[0].DependsOn); + Assert.Empty(plan.Nodes[1].DependsOn); + Assert.Equal(["tool-1", "tool-2"], plan.Nodes[2].DependsOn); + Assert.Equal("analyst", plan.Nodes[2].AgentId); + Assert.Equal(2, plan.MaxConcurrency); + } +} diff --git a/tests/MyAgent.Tests/Core/SessionManagerTests.cs b/tests/MyAgent.Tests/Core/SessionManagerTests.cs new file mode 100644 index 0000000..61167ff --- /dev/null +++ b/tests/MyAgent.Tests/Core/SessionManagerTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Options; +using MyAgent.Core; +using Xunit; + +namespace MyAgent.Tests.Core; + +public sealed class SessionManagerTests +{ + [Fact] + public async Task DebounceAsync_TwoRapidMessagesProduceOneStableBatch() + { + var manager = CreateManager(); + var now = DateTimeOffset.Now; + var first = manager.DebounceAsync(CreateMessage("1", "第一条", now)); + await Task.Delay(10); + var second = manager.DebounceAsync(CreateMessage("2", "第二条", now.AddMilliseconds(10))); + + Assert.Null(await first); + var batch = await second; + Assert.NotNull(batch); + Assert.Equal(2, batch!.Messages.Count); + Assert.Contains("第一条", batch.MergedText); + Assert.Contains("第二条", batch.MergedText); + } + + [Fact] + public async Task DebounceAsync_CompletedBatchIsConsumed() + { + var manager = CreateManager(); + var first = await manager.DebounceAsync(CreateMessage("1", "第一轮", DateTimeOffset.Now)); + var second = await manager.DebounceAsync(CreateMessage("2", "第二轮", DateTimeOffset.Now.AddSeconds(1))); + + Assert.Single(first!.Messages); + Assert.Single(second!.Messages); + Assert.Equal("第二轮", second.MergedText); + } + + [Fact] + public async Task DebounceAsync_CallerCancellationDoesNotLeaveBrokenPendingState() + { + var manager = CreateManager(); + using var cancellation = new CancellationTokenSource(); + var cancelled = manager.DebounceAsync(CreateMessage("1", "取消", DateTimeOffset.Now), cancellation.Token); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => cancelled); + var next = await manager.DebounceAsync(CreateMessage("2", "下一条", DateTimeOffset.Now.AddSeconds(1))); + + Assert.NotNull(next); + Assert.Single(next!.Messages); + } + + [Fact] + public async Task DebounceAsync_DifferentUsersReplyingToSameMessageStayIsolated() + { + var manager = CreateManager(); + var firstMessage = CreateMessage("1", "用户一", DateTimeOffset.Now, "1", "root"); + var secondMessage = CreateMessage("2", "用户二", DateTimeOffset.Now, "2", "root"); + + var first = manager.DebounceAsync(firstMessage); + var second = manager.DebounceAsync(secondMessage); + var batches = await Task.WhenAll(first, second); + + Assert.All(batches, batch => Assert.Single(batch!.Messages)); + Assert.NotEqual(batches[0]!.ThreadId, batches[1]!.ThreadId); + } + + private static SessionManager CreateManager() + { + var options = Options.Create(new ShirobotOptions + { + Fusion = new FusionOptions + { + DebounceMs = 40, + SameUserMergeWindowSec = 8, + MaxMergedMessages = 8 + } + }); + return new SessionManager(options, TimeProvider.System); + } + + private static IncomingMessage CreateMessage(string id, string text, DateTimeOffset at, string userId = "1", string? replyToMessageId = null) => new() + { + MessageId = id, + GroupId = "1", + UserId = userId, + Text = text, + CreatedAt = at, + ReplyToMessageId = replyToMessageId + }; +} diff --git a/tests/MyAgent.Tests/Core/SubagentRegistryTests.cs b/tests/MyAgent.Tests/Core/SubagentRegistryTests.cs new file mode 100644 index 0000000..55aec46 --- /dev/null +++ b/tests/MyAgent.Tests/Core/SubagentRegistryTests.cs @@ -0,0 +1,18 @@ +using MyAgent.Core; +using Xunit; + +namespace MyAgent.Tests.Core; + +public sealed class SubagentRegistryTests +{ + [Fact] + public void Registry_ContainsOnlyControlledRoles() + { + var registry = new SubagentRegistry(); + + Assert.Equal(["analyst", "memory-recall", "tool"], registry.List().Select(x => x.Id).ToArray()); + Assert.False(registry.TryGet("System.IO.File", out _)); + Assert.True(registry.TryGet("tool", out var tool)); + Assert.Equal(4, tool.MaxInstancesPerPlan); + } +} diff --git a/tests/MyAgent.Tests/Core/TaskRegistryTests.cs b/tests/MyAgent.Tests/Core/TaskRegistryTests.cs new file mode 100644 index 0000000..b9e94b1 --- /dev/null +++ b/tests/MyAgent.Tests/Core/TaskRegistryTests.cs @@ -0,0 +1,47 @@ +using MyAgent.Core; +using Xunit; + +namespace MyAgent.Tests.Core; + +public sealed class TaskRegistryTests +{ + [Fact] + public void StartLease_NewerTaskCancelsPreviousAndOldTaskCannotComplete() + { + var registry = new TaskRegistry(); + var first = CreateTask("first"); + var second = CreateTask("second"); + + var firstLease = registry.StartLease(first, ["message-1"]); + var secondLease = registry.StartLease(second, ["message-2"]); + + Assert.True(firstLease.Token.IsCancellationRequested); + Assert.True(secondLease.CancelledPrevious); + Assert.False(registry.Complete(first.TaskId, "late", firstLease.Generation)); + Assert.True(registry.IsCurrent(secondLease)); + Assert.True(registry.TryGet(first.TaskId, out var firstState)); + Assert.Equal("cancelled", firstState!.Status); + Assert.Equal("cancelled_by_newer_message", firstState.CancellationReason); + } + + [Fact] + public void Complete_IsIdempotentAndClearsCurrentOwner() + { + var registry = new TaskRegistry(); + var task = CreateTask("only"); + var lease = registry.StartLease(task, ["message-1"]); + + Assert.True(registry.Complete(task.TaskId, "ok", lease.Generation)); + Assert.False(registry.Complete(task.TaskId, "again", lease.Generation)); + Assert.False(registry.IsCurrent(lease)); + } + + private static AgentTaskState CreateTask(string id) => new() + { + TaskId = id, + ThreadId = "group:1:user:1", + GroupId = "1", + UserId = "1", + MergedText = id + }; +} diff --git a/tests/MyAgent.Tests/MyAgent.Tests.csproj b/tests/MyAgent.Tests/MyAgent.Tests.csproj new file mode 100644 index 0000000..230297e --- /dev/null +++ b/tests/MyAgent.Tests/MyAgent.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/MyAgent.Tests/Storage/SafePathSegmentTests.cs b/tests/MyAgent.Tests/Storage/SafePathSegmentTests.cs new file mode 100644 index 0000000..62435da --- /dev/null +++ b/tests/MyAgent.Tests/Storage/SafePathSegmentTests.cs @@ -0,0 +1,27 @@ +using MyAgent.Storage; +using Xunit; + +namespace MyAgent.Tests.Storage; + +public sealed class SafePathSegmentTests +{ + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("../other")] + [InlineData("group/name")] + [InlineData("group\\name")] + public void Normalize_RejectsPathTraversal(string value) + { + Assert.Throws(() => SafePathSegment.Normalize(value, "groupId")); + } + + [Theory] + [InlineData("915449089")] + [InlineData("demo-group")] + [InlineData("user_1")] + public void Normalize_AcceptsStableIdentifiers(string value) + { + Assert.Equal(value, SafePathSegment.Normalize(value, "id")); + } +}