diff --git a/docs/SYSTEM_PROMPT.md b/docs/SYSTEM_PROMPT.md index 93a5e18..ea83df3 100644 --- a/docs/SYSTEM_PROMPT.md +++ b/docs/SYSTEM_PROMPT.md @@ -193,6 +193,13 @@ refund_policy.json transferToHuman() +以下情况不要转人工,直接回答或调用合适工具: + +- 询问你是机器人还是人工客服 +- 询问人工客服服务时间 +- 询问店铺卖什么产品 +- 普通商品、订单、物流、优惠券、FAQ 咨询 + 不要承诺补偿。 不要承诺退款。 @@ -249,9 +256,9 @@ Tool 返回结果是真实结果。 # 无法解决时 -礼貌说明。 +仅当用户明确要求人工,或问题属于投诉、赔偿、金额争议、法律、账号异常、情绪激动、连续两次无法解决时,才转人工。 -转人工。 +普通信息咨询无法一次回答时,先询问必要信息或调用更合适的工具,不要直接转人工。 例如: @@ -297,4 +304,4 @@ transferToHuman() 保持简洁。 -保持专业。 \ No newline at end of file +保持专业。 diff --git a/package.json b/package.json index b971c92..558f70c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "tsx watch server/index.ts", + "dev:mcp": "tsx server/mcp/server.ts", "dev:web": "pnpm --filter ecommerce-agent-web dev", "build": "tsc -p tsconfig.json", "build:web": "pnpm --filter ecommerce-agent-web build", @@ -16,6 +17,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f212786..9318f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@3.25.76) cors: specifier: ^2.8.5 version: 2.8.6 @@ -537,6 +540,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -553,6 +562,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -862,6 +881,17 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1002,6 +1032,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1129,14 +1163,31 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1144,6 +1195,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1227,6 +1281,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1258,6 +1316,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1288,10 +1350,16 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1312,6 +1380,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1452,6 +1526,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1484,6 +1562,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -1585,6 +1667,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -1634,6 +1720,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1882,6 +1976,11 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1912,6 +2011,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2219,6 +2323,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2238,6 +2346,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -2529,6 +2659,17 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -2660,6 +2801,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -2804,8 +2951,19 @@ snapshots: etag@1.8.1: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2839,6 +2997,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2849,6 +3009,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.1.2: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2937,6 +3099,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.27: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -2975,6 +3139,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: @@ -2997,8 +3163,12 @@ snapshots: is-promise@4.0.0: {} + isexe@2.0.0: {} + jiti@1.21.7: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -3032,6 +3202,10 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} lilconfig@3.1.3: {} @@ -3126,6 +3300,8 @@ snapshots: parseurl@1.3.3: {} + path-key@3.1.1: {} + path-parse@1.0.7: {} path-to-regexp@8.4.2: {} @@ -3144,6 +3320,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + postcss-import@15.1.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -3234,6 +3412,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + require-from-string@2.0.2: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -3327,6 +3507,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -3604,6 +3790,10 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3619,4 +3809,8 @@ snapshots: yallist@3.1.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/server/agent/safetyPolicy.ts b/server/agent/safetyPolicy.ts index 6e18ca2..44514d3 100644 --- a/server/agent/safetyPolicy.ts +++ b/server/agent/safetyPolicy.ts @@ -26,7 +26,7 @@ const handoffRules: Array<{ intent: string; pattern: RegExp }> = [ { intent: 'complaint', pattern: /骗子|垃圾平台|投诉|举报|客服态度/i }, { intent: 'angry_user', pattern: /傻逼|说人话|气死|愤怒/i }, { intent: 'repeat_failure', pattern: /问三遍|一直解决不了|连续两次|还是没解决/i }, - { intent: 'human_service', pattern: /人工客服|找人工|转人工/i }, + { intent: 'human_service', pattern: /转人工|找人工|接人工|人工服务|真人客服|我要人工/i }, ]; export function evaluateSafetyPolicy(message: string): SafetyDecision | null { diff --git a/server/agent/toolCatalog.ts b/server/agent/toolCatalog.ts new file mode 100644 index 0000000..e616069 --- /dev/null +++ b/server/agent/toolCatalog.ts @@ -0,0 +1,159 @@ +import { z } from 'zod'; + +import { getCoupon } from '../tools/getCoupon.js'; +import { getFaq } from '../tools/getFaq.js'; +import { getLogistics } from '../tools/getLogistics.js'; +import { getOrder } from '../tools/getOrder.js'; +import { getProduct } from '../tools/getProduct.js'; +import { getRefundPolicy } from '../tools/getRefundPolicy.js'; +import { recommendProduct } from '../tools/recommendProduct.js'; +import { transferToHuman } from '../tools/transferToHuman.js'; + +export interface ToolDefinition { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface ToolSpec { + name: Name; + description: string; + inputSchema: Record; + parameters: Record; + required: string[]; + execute: (input: unknown) => Promise; +} + +export const toolSpecs = [ + spec( + 'getProduct', + '查询商品信息、价格、库存、颜色和规格', + { productName: z.string().trim().min(1).describe('商品名称、类别或商品 ID') }, + { productName: stringProperty('商品名称、类别或商品 ID') }, + ['productName'], + getProduct, + ), + spec( + 'recommendProduct', + '根据预算和需求推荐有库存的商品', + { + priceRange: z.number().positive().optional().describe('最高预算,可省略'), + tags: z.array(z.string().trim().min(1)).optional().describe('用途、品类或功能关键词,可省略'), + requirements: z.string().trim().min(1).optional().describe('用户的自然语言需求,可省略'), + }, + { + priceRange: { type: 'number', description: '最高预算,可省略' }, + tags: { + type: 'array', + items: { type: 'string' }, + description: '用途、品类或功能关键词,可省略', + }, + requirements: stringProperty('用户的自然语言需求,可省略'), + }, + [], + recommendProduct, + ), + spec( + 'getOrder', + '根据订单号查询订单状态', + { orderId: z.string().trim().min(1).describe('订单号,例如 ORD202600001') }, + { orderId: stringProperty('订单号,例如 ORD202600001') }, + ['orderId'], + getOrder, + ), + spec( + 'getLogistics', + '根据物流单号查询物流状态和轨迹', + { trackingNo: z.string().trim().min(1).describe('物流单号,例如 SF202600001') }, + { trackingNo: stringProperty('物流单号,例如 SF202600001') }, + ['trackingNo'], + getLogistics, + ), + spec( + 'getCoupon', + '查询当前可用的全站优惠券', + { userId: z.string().trim().min(1).optional().describe('可选用户 ID;MVP 数据不包含用户券绑定') }, + { userId: stringProperty('可选用户 ID;MVP 数据不包含用户券绑定') }, + [], + getCoupon, + ), + spec( + 'getFaq', + '查询标准 FAQ 答案', + { question: z.string().trim().min(1).describe('用户问题') }, + { question: stringProperty('用户问题') }, + ['question'], + getFaq, + ), + spec( + 'getRefundPolicy', + '查询退款、退货、运费、换货或质保规则', + { question: z.string().trim().min(1).describe('售后规则问题') }, + { question: stringProperty('售后规则问题') }, + ['question'], + getRefundPolicy, + ), + spec( + 'transferToHuman', + '将必须人工处理的问题转接人工客服', + {}, + {}, + [], + transferToHuman, + ), +] as const satisfies readonly ToolSpec[]; + +export type ToolName = (typeof toolSpecs)[number]['name']; + +export const toolDefinitions: ToolDefinition[] = toolSpecs.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: { + type: 'object', + properties: tool.parameters, + required: tool.required, + additionalProperties: false, + }, + }, +})); + +export async function executeCatalogTool( + name: string, + argumentsValue: unknown, +): Promise { + const tool = toolSpecs.find((item) => item.name === name); + if (!tool) throw new Error(`未注册的工具: ${name}`); + + const schema = z.object(tool.inputSchema); + const result = schema.safeParse(argumentsValue); + if (!result.success) throw new Error(`工具参数无效: ${name}`); + + return tool.execute(result.data); +} + +function spec( + name: Name, + description: string, + inputSchema: Record, + properties: Record, + required: string[], + execute: (input: never) => Promise, +): ToolSpec { + return { + name, + description, + inputSchema, + parameters: properties, + required, + execute: execute as (input: unknown) => Promise, + }; +} + +function stringProperty(description: string): Record { + return { type: 'string', description }; +} diff --git a/server/agent/toolRegistry.ts b/server/agent/toolRegistry.ts index ba04138..3e20035 100644 --- a/server/agent/toolRegistry.ts +++ b/server/agent/toolRegistry.ts @@ -1,119 +1,6 @@ -import { z } from 'zod'; - -import { getCoupon } from '../tools/getCoupon.js'; -import { getFaq } from '../tools/getFaq.js'; -import { getLogistics } from '../tools/getLogistics.js'; -import { getOrder } from '../tools/getOrder.js'; -import { getProduct } from '../tools/getProduct.js'; -import { getRefundPolicy } from '../tools/getRefundPolicy.js'; -import { recommendProduct } from '../tools/recommendProduct.js'; -import { transferToHuman } from '../tools/transferToHuman.js'; - -export interface ToolDefinition { - type: 'function'; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -export const toolDefinitions: ToolDefinition[] = [ - definition('getProduct', '查询商品信息、价格、库存、颜色和规格', { - productName: stringProperty('商品名称、类别或商品 ID'), - }, ['productName']), - definition('recommendProduct', '根据预算和需求推荐有库存的商品', { - priceRange: { type: 'number', description: '最高预算,可省略' }, - tags: { - type: 'array', - items: { type: 'string' }, - description: '用途、品类或功能关键词,可省略', - }, - requirements: stringProperty('用户的自然语言需求,可省略'), - }), - definition('getOrder', '根据订单号查询订单状态', { - orderId: stringProperty('订单号,例如 ORD202600001'), - }, ['orderId']), - definition('getLogistics', '根据物流单号查询物流状态和轨迹', { - trackingNo: stringProperty('物流单号,例如 SF202600001'), - }, ['trackingNo']), - definition('getCoupon', '查询当前可用的全站优惠券', { - userId: stringProperty('可选用户 ID;MVP 数据不包含用户券绑定'), - }), - definition('getFaq', '查询标准 FAQ 答案', { - question: stringProperty('用户问题'), - }, ['question']), - definition('getRefundPolicy', '查询退款、退货、运费、换货或质保规则', { - question: stringProperty('售后规则问题'), - }, ['question']), - definition('transferToHuman', '将必须人工处理的问题转接人工客服', {}), -]; - -const schemas = { - getProduct: z.object({ productName: z.string().trim().min(1) }), - recommendProduct: z.object({ - priceRange: z.number().positive().optional(), - tags: z.array(z.string().trim().min(1)).optional(), - requirements: z.string().trim().min(1).optional(), - }), - getOrder: z.object({ orderId: z.string().trim().min(1) }), - getLogistics: z.object({ trackingNo: z.string().trim().min(1) }), - getCoupon: z.object({ userId: z.string().trim().min(1).optional() }), - getFaq: z.object({ question: z.string().trim().min(1) }), - getRefundPolicy: z.object({ question: z.string().trim().min(1) }), - transferToHuman: z.object({}), -}; - -export type ToolName = keyof typeof schemas; - -export async function executeTool(name: string, argumentsValue: unknown): Promise { - if (!(name in schemas)) throw new Error(`未注册的工具: ${name}`); - - const toolName = name as ToolName; - const result = schemas[toolName].safeParse(argumentsValue); - if (!result.success) throw new Error(`工具参数无效: ${name}`); - - switch (toolName) { - case 'getProduct': - return getProduct(schemas.getProduct.parse(argumentsValue)); - case 'recommendProduct': - return recommendProduct(schemas.recommendProduct.parse(argumentsValue)); - case 'getOrder': - return getOrder(schemas.getOrder.parse(argumentsValue)); - case 'getLogistics': - return getLogistics(schemas.getLogistics.parse(argumentsValue)); - case 'getCoupon': - return getCoupon(schemas.getCoupon.parse(argumentsValue)); - case 'getFaq': - return getFaq(schemas.getFaq.parse(argumentsValue)); - case 'getRefundPolicy': - return getRefundPolicy(schemas.getRefundPolicy.parse(argumentsValue)); - case 'transferToHuman': - return transferToHuman(schemas.transferToHuman.parse(argumentsValue)); - } -} - -function definition( - name: string, - description: string, - properties: Record, - required: string[] = [], -): ToolDefinition { - return { - type: 'function', - function: { - name, - description, - parameters: { - type: 'object', - properties, - required, - additionalProperties: false, - }, - }, - }; -} - -function stringProperty(description: string): Record { - return { type: 'string', description }; -} +export { + executeCatalogTool as executeTool, + toolDefinitions, + type ToolDefinition, + type ToolName, +} from './toolCatalog.js'; diff --git a/server/index.ts b/server/index.ts index 80fcb88..67c848a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,11 +2,13 @@ import { createApp } from './app.js'; import { loadConfig } from './config/env.js'; import { createChatService } from './services/chatService.js'; import { createLlmService } from './services/llmService.js'; +import { createInMemoryMcpToolExecutor } from './mcp/mcpToolExecutor.js'; import { logger } from './utils/logger.js'; const config = loadConfig(); const llm = createLlmService(config.deepSeek); -const chatService = createChatService({ llm }); +const toolExecutor = await createInMemoryMcpToolExecutor(); +const chatService = createChatService({ llm, toolExecutor }); const app = createApp({ chatService }); app.listen(config.port, () => { diff --git a/server/mcp/ecommerceMcpServer.ts b/server/mcp/ecommerceMcpServer.ts new file mode 100644 index 0000000..c3433c7 --- /dev/null +++ b/server/mcp/ecommerceMcpServer.ts @@ -0,0 +1,35 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { toolSpecs } from '../agent/toolCatalog.js'; + +export function createEcommerceMcpServer(): McpServer { + const server = new McpServer({ + name: 'ecommerce-agent-tools', + version: '0.1.0', + }); + + for (const tool of toolSpecs) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.inputSchema, + }, + async (args) => { + const result = await tool.execute(args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: asStructuredContent(result), + }; + }, + ); + } + + return server; +} + +function asStructuredContent(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/server/mcp/mcpToolExecutor.ts b/server/mcp/mcpToolExecutor.ts new file mode 100644 index 0000000..4f8b902 --- /dev/null +++ b/server/mcp/mcpToolExecutor.ts @@ -0,0 +1,70 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + +import type { ToolDefinition } from '../agent/toolCatalog.js'; +import { createEcommerceMcpServer } from './ecommerceMcpServer.js'; + +export interface McpToolExecutor { + listTools(): Promise; + execute(name: string, argumentsValue: unknown): Promise; + close(): Promise; +} + +export async function createInMemoryMcpToolExecutor(): Promise { + const server = createEcommerceMcpServer(); + const client = new Client({ + name: 'ecommerce-agent-api', + version: '0.1.0', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + return { + async listTools() { + const { tools } = await client.listTools(); + return tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description ?? '', + parameters: { + ...tool.inputSchema, + additionalProperties: false, + }, + }, + })); + }, + async execute(name, argumentsValue) { + const result = await client.callTool({ + name, + arguments: toRecord(argumentsValue), + }); + + if ('toolResult' in result) return result.toolResult; + if (result.structuredContent) return result.structuredContent; + + const text = result.content.find((item) => item.type === 'text')?.text; + if (!text) return result; + + try { + return JSON.parse(text); + } catch { + return text; + } + }, + async close() { + await Promise.allSettled([client.close(), server.close()]); + }, + }; +} + +function toRecord(value: unknown): Record { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + return {}; +} diff --git a/server/mcp/server.ts b/server/mcp/server.ts new file mode 100644 index 0000000..eb4a5e1 --- /dev/null +++ b/server/mcp/server.ts @@ -0,0 +1,8 @@ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import { createEcommerceMcpServer } from './ecommerceMcpServer.js'; + +const server = createEcommerceMcpServer(); +const transport = new StdioServerTransport(); + +await server.connect(transport); diff --git a/server/services/chatService.ts b/server/services/chatService.ts index cc16f11..3234d35 100644 --- a/server/services/chatService.ts +++ b/server/services/chatService.ts @@ -7,8 +7,8 @@ import { toolDefinitions, type ToolName, } from '../agent/toolRegistry.js'; -import { transferToHuman } from '../tools/transferToHuman.js'; import type { LlmMessage, LlmService } from './llmService.js'; +import type { McpToolExecutor } from '../mcp/mcpToolExecutor.js'; export interface ChatRequest { message: string; @@ -27,8 +27,15 @@ interface ConversationState { failedToolResults: number; } +interface ResponseContext { + intent: string; + message: LlmMessage; + toolCalls: string[]; +} + export function createChatService(options: { llm: LlmService; + toolExecutor?: McpToolExecutor; maxToolRounds?: number; }) { const conversations = new Map(); @@ -51,7 +58,7 @@ export function createChatService(options: { } if (safety?.action === 'transferToHuman') { - const handoff = await transferToHuman({}); + const handoff = await callTransferToHuman(options.toolExecutor); const message = formatHandoff(handoff); remember(state, request.message, message); return { @@ -63,14 +70,22 @@ export function createChatService(options: { } state.messages.push({ role: 'user', content: request.message }); + const responseContext = await getResponseContext( + request.message, + options.toolExecutor, + ); const messages: LlmMessage[] = [ { role: 'system', content: await getSystemPrompt() }, + ...(responseContext ? [responseContext.message] : []), ...state.messages, ]; - const calledTools: string[] = []; + const calledTools: string[] = responseContext?.toolCalls ?? []; for (let round = 0; round < maxToolRounds; round += 1) { - const response = await options.llm.functionCalling(messages, toolDefinitions); + const tools = options.toolExecutor + ? await options.toolExecutor.listTools() + : toolDefinitions; + const response = await options.llm.functionCalling(messages, tools); if (response.toolCalls.length === 0) { const message = response.content @@ -81,7 +96,7 @@ export function createChatService(options: { return { conversationId, message, - intent: inferIntent(calledTools), + intent: inferIntent(calledTools) ?? responseContext?.intent, toolCalls: calledTools, }; } @@ -96,7 +111,15 @@ export function createChatService(options: { calledTools.push(call.name); let result: unknown; try { - result = await executeTool(call.name, JSON.parse(call.arguments)); + const args = JSON.parse(call.arguments); + result = + call.name === 'transferToHuman' + ? { + success: true, + message: + '当前问题不需要转人工。请直接回答用户问题,或调用 getProduct、recommendProduct、getFaq、getOrder、getLogistics、getCoupon、getRefundPolicy 中更合适的工具。', + } + : await callTool(options.toolExecutor, call.name, args); } catch (error) { result = { success: false, @@ -115,7 +138,7 @@ export function createChatService(options: { } if (state.failedToolResults >= 2) { - const handoff = await transferToHuman({}); + const handoff = await callTransferToHuman(options.toolExecutor); const message = formatHandoff(handoff); state.messages.push({ role: 'assistant', content: message }); return { @@ -127,7 +150,7 @@ export function createChatService(options: { } } - const handoff = await transferToHuman({}); + const handoff = await callTransferToHuman(options.toolExecutor); const message = formatHandoff(handoff); state.messages.push({ role: 'assistant', content: message }); return { @@ -140,6 +163,64 @@ export function createChatService(options: { }; } +async function getResponseContext( + message: string, + toolExecutor: McpToolExecutor | undefined, +): Promise { + const normalized = message.trim(); + + if (/你是.*(?:机器人|人工客服|真人)|(?:机器人|人工客服|真人).*(?:吗|还是)/i.test(normalized)) { + return { + intent: 'assistant_identity', + message: { + role: 'system', + content: + '意图:assistant_identity。当前用户在询问客服身份。事实:你是智能电商客服助手,不是真人客服,也不是人工客服;你可以处理商品咨询、商品推荐、订单查询、物流查询、优惠券查询和售后规则咨询;只有投诉、赔偿、金额争议、法律问题、账号异常、情绪激动或用户明确要求人工时才转人工。请根据用户问法自然回答,不要逐字复述事实,不要调用 transferToHuman。', + }, + toolCalls: [], + }; + } + + if (/人工客服.*(?:什么时候|几点|上班|服务时间)|(?:什么时候|几点).*(?:人工客服|客服).*上班/i.test(normalized)) { + const handoff = await callTransferToHuman(toolExecutor); + const serviceHours = + isHandoffResult(handoff) && handoff.success + ? { + contact: handoff.contact ?? 'service001', + serviceHours: handoff.serviceHours ?? '09:00-21:00', + } + : { contact: 'service001', serviceHours: '09:00-21:00' }; + + return { + intent: 'assistant_identity', + message: { + role: 'system', + content: `意图:human_service_info。当前用户在询问人工客服服务时间。事实:人工客服编号是 ${serviceHours.contact},服务时间是 ${serviceHours.serviceHours}。请直接回答服务时间,可以顺带说明普通商品、订单、物流、优惠券问题你也可以先处理。不要说“需要人工进一步处理”,不要调用 transferToHuman。`, + }, + toolCalls: [], + }; + } + + if (/卖什么|有什么产品|主营.*产品|经营.*产品/.test(normalized)) { + const result = await callTool(toolExecutor, 'recommendProduct', {}); + if (isRecommendResult(result) && result.success && result.products.length > 0) { + const categories = [...new Set(result.products.map((product) => product.category))]; + return { + intent: 'recommend_product', + message: { + role: 'system', + content: `意图:store_scope。当前用户在询问店铺经营范围。事实:店铺主要销售这些品类:${categories.join('、')}。可参考商品:${result.products + .map((product) => product.name) + .join('、')}。请自然概括,不要长篇列完整参数;可以邀请用户补充预算或用途以便推荐。不要调用 transferToHuman。`, + }, + toolCalls: ['recommendProduct'], + }; + } + } + + return null; +} + function remember(state: ConversationState, userMessage: string, assistantMessage: string) { state.messages.push( { role: 'user', content: userMessage }, @@ -161,13 +242,52 @@ function isFailedToolResult(value: unknown): boolean { ); } -function formatHandoff(result: Awaited>): string { +async function callTool( + toolExecutor: McpToolExecutor | undefined, + name: string, + args: unknown, +): Promise { + return toolExecutor ? toolExecutor.execute(name, args) : executeTool(name, args); +} + +async function callTransferToHuman( + toolExecutor: McpToolExecutor | undefined, +): Promise { + return callTool(toolExecutor, 'transferToHuman', {}); +} + +function formatHandoff(result: unknown): string { + if (!isHandoffResult(result)) { + return '抱歉,这个问题需要人工客服进一步处理,请稍后再试。'; + } + if (!result.success || !result.contact) { return '抱歉,这个问题需要人工客服进一步处理,请稍后再试。'; } return `抱歉,这个问题需要人工客服进一步处理。人工客服:${result.contact}(服务时间 ${result.serviceHours ?? '请以客服实际在线时间为准'})。`; } +function isHandoffResult(value: unknown): value is { + success: boolean; + contact?: string; + serviceHours?: string; +} { + return typeof value === 'object' && value !== null && 'success' in value; +} + +function isRecommendResult(value: unknown): value is { + success: boolean; + products: Array<{ name: string; category: string }>; +} { + return ( + typeof value === 'object' && + value !== null && + 'success' in value && + 'products' in value && + Array.isArray(value.products) + ); +} + const toolIntents: Partial> = { getProduct: 'query_product', recommendProduct: 'recommend_product', diff --git a/tests/chatService.test.ts b/tests/chatService.test.ts index 412b04e..8453db5 100644 --- a/tests/chatService.test.ts +++ b/tests/chatService.test.ts @@ -5,6 +5,8 @@ import type { LlmService } from '../server/services/llmService.js'; describe('chatService', () => { it('executes a model-requested tool and returns the final model reply', async () => { + const execute = vi.fn().mockResolvedValue({ success: true }); + const toolExecutor = fakeToolExecutor({ execute }); const functionCalling = vi .fn() .mockResolvedValueOnce({ @@ -18,13 +20,19 @@ describe('chatService', () => { ], }) .mockResolvedValueOnce({ content: '订单已签收。', toolCalls: [] }); - const service = createChatService({ llm: fakeLlm(functionCalling) }); + const service = createChatService({ + llm: fakeLlm(functionCalling), + toolExecutor, + }); const result = await service.send({ message: '查询 ORD202600001' }); expect(result.message).toBe('订单已签收。'); expect(result.toolCalls).toEqual(['getOrder']); expect(result.intent).toBe('query_order'); + expect(execute).toHaveBeenCalledWith('getOrder', { + orderId: 'ORD202600001', + }); expect(functionCalling).toHaveBeenCalledTimes(2); expect(functionCalling.mock.calls[1]?.[0]).toEqual( expect.arrayContaining([ @@ -45,6 +53,94 @@ describe('chatService', () => { expect(functionCalling).not.toHaveBeenCalled(); }); + it('does not honor model-requested handoff for ordinary informational questions', async () => { + const execute = vi.fn(); + const functionCalling = vi + .fn() + .mockResolvedValueOnce({ + content: null, + toolCalls: [ + { + id: 'call-1', + name: 'transferToHuman', + arguments: '{}', + }, + ], + }) + .mockResolvedValueOnce({ + content: '我是智能电商客服助手,可以帮您查询商品、订单、物流和优惠券。', + toolCalls: [], + }); + const service = createChatService({ + llm: fakeLlm(functionCalling), + toolExecutor: fakeToolExecutor({ execute }), + }); + + const result = await service.send({ message: '支持开发票吗?' }); + + expect(result.message).toContain('智能电商客服助手'); + expect(execute).not.toHaveBeenCalledWith('transferToHuman', {}); + expect(functionCalling.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + content: expect.stringContaining('当前问题不需要转人工'), + }), + ]), + ); + }); + + it('lets the model compose common identity answers with guardrail context', async () => { + const functionCalling = vi.fn().mockResolvedValue({ + content: '我是智能客服,不是真人客服;商品、订单和物流问题我都可以先帮您处理。', + toolCalls: [], + }); + const service = createChatService({ llm: fakeLlm(functionCalling) }); + + const result = await service.send({ message: '你是人工客服还是机器人?' }); + + expect(result.intent).toBe('assistant_identity'); + expect(result.toolCalls).toEqual([]); + expect(result.message).toBe('我是智能客服,不是真人客服;商品、订单和物流问题我都可以先帮您处理。'); + expect(functionCalling).toHaveBeenCalledTimes(1); + expect(functionCalling.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: expect.stringContaining('assistant_identity'), + }), + ]), + ); + }); + + it('lets the model vary identity replies by the user wording', async () => { + const functionCalling = vi.fn(async (messages: Parameters[0]) => { + const latestUser = [...messages] + .reverse() + .find((message) => message.role === 'user')?.content; + return { + content: + latestUser === '你是机器人吗' + ? '是的,我是智能客服助手,可以先帮您查商品和订单。' + : latestUser === '你是真人吗' + ? '不是人工客服,我是智能客服;需要人工处理时会帮您转接。' + : '我是智能电商客服助手,不是人工客服,可以先帮您处理常见购物问题。', + toolCalls: [], + }; + }); + const service = createChatService({ llm: fakeLlm(functionCalling) }); + + const robot = await service.send({ message: '你是机器人吗' }); + const human = await service.send({ message: '你是真人吗' }); + const either = await service.send({ message: '你是机器人还是人工' }); + + expect(new Set([robot.message, human.message, either.message]).size).toBe(3); + expect(robot.message).toContain('智能客服助手'); + expect(human.message).toContain('不是人工客服'); + expect(either.message).toContain('智能电商客服助手'); + expect(functionCalling).toHaveBeenCalledTimes(3); + }); + it('refuses prompt extraction without calling the model', async () => { const functionCalling = vi.fn(); const service = createChatService({ llm: fakeLlm(functionCalling) }); @@ -96,3 +192,40 @@ function fakeLlm(functionCalling: LlmService['functionCalling']): LlmService { stream: async function* () {}, }; } + +function fakeToolExecutor(overrides: { + execute?: (name: string, args: unknown) => Promise; +} = {}) { + return { + listTools: async () => [ + { + type: 'function' as const, + function: { + name: 'getOrder', + description: '查询订单', + parameters: { + type: 'object', + properties: { orderId: { type: 'string' } }, + required: ['orderId'], + additionalProperties: false, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'transferToHuman', + description: '转人工', + parameters: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + ], + execute: overrides.execute ?? vi.fn().mockResolvedValue({ success: true }), + close: async () => {}, + }; +} diff --git a/tests/mcpToolExecutor.test.ts b/tests/mcpToolExecutor.test.ts new file mode 100644 index 0000000..10d4171 --- /dev/null +++ b/tests/mcpToolExecutor.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryMcpToolExecutor } from '../server/mcp/mcpToolExecutor.js'; + +describe('MCP tool executor', () => { + it('lists the MVP ecommerce tools through MCP', async () => { + const executor = await createInMemoryMcpToolExecutor(); + + try { + const tools = await executor.listTools(); + + expect(tools.map((tool) => tool.function.name)).toEqual([ + 'getProduct', + 'recommendProduct', + 'getOrder', + 'getLogistics', + 'getCoupon', + 'getFaq', + 'getRefundPolicy', + 'transferToHuman', + ]); + } finally { + await executor.close(); + } + }); + + it('executes ecommerce tools through MCP', async () => { + const executor = await createInMemoryMcpToolExecutor(); + + try { + const result = await executor.execute('getOrder', { + orderId: 'ORD202600001', + }); + + expect(result).toMatchObject({ success: true }); + } finally { + await executor.close(); + } + }); +}); diff --git a/tests/safetyPolicy.test.ts b/tests/safetyPolicy.test.ts index a051d7a..eca8a78 100644 --- a/tests/safetyPolicy.test.ts +++ b/tests/safetyPolicy.test.ts @@ -25,4 +25,12 @@ describe('evaluateSafetyPolicy', () => { it('allows an ordinary product question to reach the model workflow', () => { expect(evaluateSafetyPolicy('这个耳机支持苹果手机吗')).toBeNull(); }); + + it.each([ + '你们店人工客服什么时候上班?', + '你是人工客服还是机器人?', + '你们店卖什么产品?', + ])('allows informational service questions to reach the model workflow: %s', (input) => { + expect(evaluateSafetyPolicy(input)).toBeNull(); + }); }); diff --git a/web/src/components/InputBox.tsx b/web/src/components/InputBox.tsx index dc23aae..3ff4d92 100644 --- a/web/src/components/InputBox.tsx +++ b/web/src/components/InputBox.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, type KeyboardEvent, useState } from 'react'; +import { type FormEvent, type KeyboardEvent, useEffect, useRef, useState } from 'react'; export function InputBox({ disabled, @@ -8,6 +8,11 @@ export function InputBox({ onSend(message: string): Promise; }) { const [value, setValue] = useState(''); + const textareaRef = useRef(null); + + useEffect(() => { + if (!disabled) textareaRef.current?.focus(); + }, [disabled]); async function submit(event?: FormEvent) { event?.preventDefault(); @@ -28,6 +33,7 @@ export function InputBox({