feat: v4.0.0 大版本升级 - SQLite3 + ReAct Agent

- P0: 存储层 IndexedDB → SQLite3 (better-sqlite3),7张表 + FTS5全文搜索
- P1: Agent Engine 升级为 ReAct 模式(思考→行动→观察→反思)
- P2: 新增 4 个工具(memory_search, memory_add, session_list, session_read)
- P3: 上下文管理器(滑动窗口 + 摘要压缩 + 记忆注入)
- P4: UI 改版(ReAct 执行面板样式 + 思考过程卡片)
- P5: IndexedDB → SQLite 数据迁移支持
- MAX_LOOPS 10→15, MAX_LOOP_TIME 5min→10min, 错误自动重试 2 次
This commit is contained in:
thzxx
2026-04-17 12:35:42 +08:00
parent 5fb36a93e6
commit 26cbac71fe
14 changed files with 2001 additions and 332 deletions
+213 -28
View File
@@ -1,13 +1,17 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "3.3.0", "version": "3.3.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "3.3.0", "version": "3.3.4",
"license": "MIT", "license": "MIT",
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.9.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20.17.0", "@types/node": "^20.17.0",
"electron": "^33.4.11", "electron": "^33.4.11",
@@ -1420,6 +1424,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cacheable-request": { "node_modules/@types/cacheable-request": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -1488,7 +1501,6 @@
"version": "20.19.39", "version": "20.19.39",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.39.tgz", "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1933,7 +1945,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1950,11 +1961,33 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
"integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
@@ -2005,7 +2038,6 @@
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -2649,7 +2681,6 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mimic-response": "^3.1.0" "mimic-response": "^3.1.0"
@@ -2665,7 +2696,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -2674,6 +2704,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/defaults": { "node_modules/defaults": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz",
@@ -2756,7 +2795,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3176,7 +3214,6 @@
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
@@ -3319,6 +3356,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -3382,6 +3428,12 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz",
@@ -3473,9 +3525,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true, "license": "MIT"
"license": "MIT",
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -3623,6 +3673,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
@@ -3953,7 +4009,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4013,7 +4068,12 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true, "license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
@@ -4533,7 +4593,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4649,6 +4708,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -4675,6 +4740,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz",
@@ -4689,7 +4760,6 @@
"version": "3.89.0", "version": "3.89.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz", "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"semver": "^7.3.5" "semver": "^7.3.5"
@@ -4702,7 +4772,6 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -4853,7 +4922,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
@@ -5075,6 +5143,33 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -5118,7 +5213,6 @@
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
@@ -5148,6 +5242,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -5165,7 +5274,6 @@
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
@@ -5377,7 +5485,6 @@
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5493,6 +5600,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -5655,7 +5807,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
@@ -5719,6 +5870,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sumchecker": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -5763,13 +5923,29 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -5870,6 +6046,18 @@
"utf8-byte-length": "^1.0.1" "utf8-byte-length": "^1.0.1"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz",
@@ -5902,7 +6090,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unique-filename": { "node_modules/unique-filename": {
@@ -5962,7 +6149,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/verror": { "node_modules/verror": {
@@ -6118,7 +6304,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
+8 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "3.3.4", "version": "4.0.0",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
@@ -31,7 +31,9 @@
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
"arch": ["x64"] "arch": [
"x64"
]
} }
], ],
"icon": "assets/icons/llama.ico", "icon": "assets/icons/llama.ico",
@@ -54,5 +56,9 @@
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^5.4.0" "vite": "^5.4.0"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.9.0"
} }
} }
+431
View File
@@ -0,0 +1,431 @@
/**
* Metona Ollama Desktop - SQLite 数据库层
* v4.0: 替代 IndexedDB,使用 better-sqlite3
*/
import Database from 'better-sqlite3';
import * as path from 'path';
import { app } from 'electron';
let db: Database.Database | null = null;
/** 获取数据库实例 */
export function getDb(): Database.Database {
if (!db) throw new Error('数据库未初始化,请先调用 initDatabase()');
return db;
}
/** 初始化数据库 */
export function initDatabase(): Database.Database {
if (db) return db;
const dbPath = path.join(app.getPath('userData'), 'metona.db');
db = new Database(dbPath);
// 性能优化
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
// 创建表
db.exec(`
-- 会话表
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT NOT NULL,
system_prompt TEXT,
parent_id TEXT,
status TEXT DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES sessions(id)
);
-- 消息表
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
thinking TEXT,
images TEXT,
tool_calls TEXT,
tool_name TEXT,
eval_count INTEGER,
total_duration INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
-- 工具调用记录表
CREATE TABLE IF NOT EXISTS tool_calls (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
arguments TEXT,
result TEXT,
status TEXT DEFAULT 'pending',
duration_ms INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, tool_name);
-- 记忆表
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
content TEXT NOT NULL,
importance INTEGER DEFAULT 5,
tags TEXT,
source TEXT,
session_id TEXT,
use_count INTEGER DEFAULT 0,
embedding TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC);
-- 记忆 FTS5 全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content, tags, type,
content='memories',
content_rowid='rowid'
);
-- 设置表
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- 执行轨迹表(Agent 可观测性)
CREATE TABLE IF NOT EXISTS traces (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
step_index INTEGER,
thought TEXT,
action TEXT,
action_input TEXT,
observation TEXT,
loop_count INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at);
`);
return db;
}
// ─── Sessions CRUD ───
export interface SessionRow {
id: string;
title: string;
model: string;
system_prompt: string | null;
parent_id: string | null;
status: string;
created_at: number;
updated_at: number;
}
export interface MessageRow {
id: string;
session_id: string;
role: string;
content: string | null;
thinking: string | null;
images: string | null;
tool_calls: string | null;
tool_name: string | null;
eval_count: number | null;
total_duration: number | null;
created_at: number;
}
export interface MemoryRow {
id: string;
type: string;
content: string;
importance: number;
tags: string | null;
source: string | null;
session_id: string | null;
use_count: number;
embedding: string | null;
created_at: number;
updated_at: number;
last_used_at: number;
}
export interface SettingRow {
key: string;
value: string;
updated_at: number;
}
export interface ToolCallRow {
id: string;
message_id: string;
session_id: string;
tool_name: string;
arguments: string | null;
result: string | null;
status: string;
duration_ms: number | null;
created_at: number;
}
export interface TraceRow {
id: string;
session_id: string;
step_index: number | null;
thought: string | null;
action: string | null;
action_input: string | null;
observation: string | null;
loop_count: number | null;
created_at: number;
}
export function saveSession(session: SessionRow): string {
const d = getDb();
d.prepare(`INSERT OR REPLACE INTO sessions (id, title, model, system_prompt, parent_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
session.id, session.title, session.model, session.system_prompt, session.parent_id,
session.status || 'active', session.created_at, session.updated_at
);
return session.id;
}
export function getSession(id: string): SessionRow | null {
const d = getDb();
return d.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as SessionRow | null;
}
export function getAllSessions(): SessionRow[] {
const d = getDb();
return d.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all() as SessionRow[];
}
export function deleteSession(id: string): void {
const d = getDb();
d.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
export function clearAllSessions(): void {
const d = getDb();
d.exec('DELETE FROM sessions');
}
// ─── Messages CRUD ───
export function saveMessage(msg: MessageRow): string {
const d = getDb();
d.prepare(`INSERT OR REPLACE INTO messages (id, session_id, role, content, thinking, images, tool_calls, tool_name, eval_count, total_duration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
msg.id, msg.session_id, msg.role, msg.content, msg.thinking, msg.images,
msg.tool_calls, msg.tool_name, msg.eval_count, msg.total_duration, msg.created_at
);
return msg.id;
}
export function getMessagesBySession(sessionId: string): MessageRow[] {
const d = getDb();
return d.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as MessageRow[];
}
export function deleteMessagesBySession(sessionId: string): void {
const d = getDb();
d.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
}
// ─── Memories CRUD ───
export function saveMemory(entry: MemoryRow): string {
const d = getDb();
d.prepare(`INSERT OR REPLACE INTO memories (id, type, content, importance, tags, source, session_id, use_count, embedding, created_at, updated_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
entry.id, entry.type, entry.content, entry.importance, entry.tags,
entry.source, entry.session_id, entry.use_count, entry.embedding,
entry.created_at, entry.updated_at, entry.last_used_at
);
// FTS 同步
try {
d.prepare("DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)").run(entry.id);
d.prepare("INSERT INTO memories_fts(rowid, content, tags, type) SELECT rowid, content, tags, type FROM memories WHERE id = ?").run(entry.id);
} catch { /* FTS 同步失败不影响主流程 */ }
return entry.id;
}
export function getMemory(id: string): MemoryRow | null {
const d = getDb();
return d.prepare('SELECT * FROM memories WHERE id = ?').get(id) as MemoryRow | null;
}
export function getAllMemories(): MemoryRow[] {
const d = getDb();
return d.prepare('SELECT * FROM memories ORDER BY importance DESC, updated_at DESC').all() as MemoryRow[];
}
export function getMemoriesByType(type: string): MemoryRow[] {
const d = getDb();
return d.prepare('SELECT * FROM memories WHERE type = ? ORDER BY importance DESC').all(type) as MemoryRow[];
}
export function deleteMemory(id: string): void {
const d = getDb();
try {
d.prepare("DELETE FROM memories_fts WHERE rowid IN (SELECT rowid FROM memories WHERE id = ?)").run(id);
} catch { /* ignore */ }
d.prepare('DELETE FROM memories WHERE id = ?').run(id);
}
export function clearAllMemories(): void {
const d = getDb();
d.exec('DELETE FROM memories');
try { d.exec('DELETE FROM memories_fts'); } catch { /* ignore */ }
}
export function searchMemoriesFTS(query: string, limit = 10): MemoryRow[] {
const d = getDb();
try {
// FTS5 搜索
const ftsQuery = query.replace(/[^\w\u4e00-\u9fff\s]/g, ' ').split(/\s+/).filter(w => w.length > 0).map(w => `"${w}"*`).join(' OR ');
if (!ftsQuery) return [];
return d.prepare(`
SELECT m.* FROM memories m
JOIN memories_fts f ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY rank
LIMIT ?
`).all(ftsQuery, limit) as MemoryRow[];
} catch {
// FTS 失败回退到 LIKE 搜索
return d.prepare(`
SELECT * FROM memories
WHERE content LIKE ? OR tags LIKE ?
ORDER BY importance DESC
LIMIT ?
`).all(`%${query}%`, `%${query}%`, limit) as MemoryRow[];
}
}
// ─── Settings CRUD ───
export function saveSetting(key: string, value: unknown): void {
const d = getDb();
d.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)').run(
key, JSON.stringify(value), Date.now()
);
}
export function getSetting<T = unknown>(key: string, defaultValue: T | null = null): T {
const d = getDb();
const row = d.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row) return defaultValue as T;
try {
return JSON.parse(row.value) as T;
} catch {
return row.value as unknown as T;
}
}
// ─── Tool Calls CRUD ───
export function saveToolCall(tc: ToolCallRow): string {
const d = getDb();
d.prepare(`INSERT OR REPLACE INTO tool_calls (id, message_id, session_id, tool_name, arguments, result, status, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
tc.id, tc.message_id, tc.session_id, tc.tool_name, tc.arguments, tc.result, tc.status, tc.duration_ms, tc.created_at
);
return tc.id;
}
export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
const d = getDb();
return d.prepare('SELECT * FROM tool_calls WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as ToolCallRow[];
}
// ─── Traces CRUD ───
export function saveTrace(trace: TraceRow): string {
const d = getDb();
d.prepare(`INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.created_at
);
return trace.id;
}
export function getTracesBySession(sessionId: string): TraceRow[] {
const d = getDb();
return d.prepare('SELECT * FROM traces WHERE session_id = ? ORDER BY step_index ASC').all(sessionId) as TraceRow[];
}
// ─── Export/Import ───
export interface ExportData {
sessions: SessionRow[];
messages: MessageRow[];
memories: MemoryRow[];
settings: Array<{ key: string; value: unknown }>;
exportedAt: number;
}
export function exportAllSessions(): ExportData {
const d = getDb();
const sessions = d.prepare('SELECT * FROM sessions').all() as SessionRow[];
const messages = d.prepare('SELECT * FROM messages').all() as MessageRow[];
const memories = d.prepare('SELECT * FROM memories').all() as MemoryRow[];
const settingsRows = d.prepare('SELECT * FROM settings').all() as SettingRow[];
const settings = settingsRows.map(r => ({ key: r.key, value: (() => { try { return JSON.parse(r.value); } catch { return r.value; } })() }));
return { sessions, messages, memories, settings, exportedAt: Date.now() };
}
export function importSessions(data: ExportData): { imported: number; skipped: number } {
const d = getDb();
let imported = 0;
let skipped = 0;
const txn = d.transaction(() => {
for (const session of data.sessions) {
const existing = d.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id);
if (existing) { skipped++; continue; }
saveSession(session);
imported++;
}
for (const msg of data.messages) {
const sessionExists = d.prepare('SELECT id FROM sessions WHERE id = ?').get(msg.session_id);
if (sessionExists) saveMessage(msg);
}
for (const mem of data.memories) {
const existing = d.prepare('SELECT id FROM memories WHERE id = ?').get(mem.id);
if (!existing) saveMemory(mem);
}
for (const s of data.settings) {
saveSetting(s.key, s.value);
}
});
txn();
return { imported, skipped };
}
// ─── Close ───
export function closeDatabase(): void {
if (db) {
db.close();
db = null;
}
}
+120
View File
@@ -7,6 +7,16 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { mainWindow } from './main.js'; import { mainWindow } from './main.js';
import { showNotification } from './utils.js'; import { showNotification } from './utils.js';
import {
initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions,
saveMessage, getMessagesBySession,
saveMemory, getMemory, getAllMemories, getMemoriesByType, deleteMemory, clearAllMemories, searchMemoriesFTS,
saveSetting, getSetting,
saveToolCall, getToolCallsBySession,
saveTrace, getTracesBySession,
exportAllSessions, importSessions
} from './db/sqlite.js';
import type { ExportData } from './db/sqlite.js';
/** 发送日志到渲染进程日志面板 */ /** 发送日志到渲染进程日志面板 */
function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void { function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
@@ -251,4 +261,114 @@ export function setupIPC(): void {
sendLog('info', `🔧 cmd:kill`, killed ? '已终止' : '无正在运行的工具命令'); sendLog('info', `🔧 cmd:kill`, killed ? '已终止' : '无正在运行的工具命令');
return { killed }; return { killed };
}); });
// ══════════════════════════════════════════════
// SQLite 数据库 IPC Handlers (v4.0)
// ══════════════════════════════════════════════
// 初始化数据库
initDatabase();
sendLog('success', '📦 SQLite 数据库已初始化');
// Sessions
ipcMain.handle('db:saveSession', (_, session) => {
try { return { success: true, id: saveSession(session) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getSession', (_, id) => {
try { return getSession(id); }
catch { return null; }
});
ipcMain.handle('db:getAllSessions', () => {
try { return getAllSessions(); }
catch { return []; }
});
ipcMain.handle('db:deleteSession', (_, id) => {
try { deleteSession(id); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:clearAllSessions', () => {
try { clearAllSessions(); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
// Messages
ipcMain.handle('db:saveMessage', (_, msg) => {
try { return { success: true, id: saveMessage(msg) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getMessages', (_, sessionId) => {
try { return getMessagesBySession(sessionId); }
catch { return []; }
});
// Memories
ipcMain.handle('db:saveMemory', (_, entry) => {
try { return { success: true, id: saveMemory(entry) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getMemory', (_, id) => {
try { return getMemory(id); }
catch { return null; }
});
ipcMain.handle('db:getAllMemories', () => {
try { return getAllMemories(); }
catch { return []; }
});
ipcMain.handle('db:getMemoriesByType', (_, type) => {
try { return getMemoriesByType(type); }
catch { return []; }
});
ipcMain.handle('db:deleteMemory', (_, id) => {
try { deleteMemory(id); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:clearAllMemories', () => {
try { clearAllMemories(); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:searchMemories', (_, query: string, limit?: number) => {
try { return searchMemoriesFTS(query, limit ?? 10); }
catch { return []; }
});
// Settings
ipcMain.handle('db:saveSetting', (_, key: string, value: unknown) => {
try { saveSetting(key, value); return { success: true }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getSetting', (_, key: string, defaultValue?: unknown) => {
try { return getSetting(key, defaultValue ?? null); }
catch { return defaultValue ?? null; }
});
// Tool Calls
ipcMain.handle('db:saveToolCall', (_, tc) => {
try { return { success: true, id: saveToolCall(tc) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getToolCalls', (_, sessionId) => {
try { return getToolCallsBySession(sessionId); }
catch { return []; }
});
// Traces
ipcMain.handle('db:saveTrace', (_, trace) => {
try { return { success: true, id: saveTrace(trace) }; }
catch (err) { return { success: false, error: (err as Error).message }; }
});
ipcMain.handle('db:getTraces', (_, sessionId) => {
try { return getTracesBySession(sessionId); }
catch { return []; }
});
// Export/Import
ipcMain.handle('db:exportSessions', () => {
try { return exportAllSessions(); }
catch (err) { sendLog('error', '导出失败', (err as Error).message); return null; }
});
ipcMain.handle('db:importSessions', (_, data: ExportData) => {
try { return importSessions(data); }
catch (err) { sendLog('error', '导入失败', (err as Error).message); return { imported: 0, skipped: 0 }; }
});
} }
+24
View File
@@ -43,6 +43,30 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
onMainLog: (callback: (data: { level: string; message: string; detail?: string }) => void) => { onMainLog: (callback: (data: { level: string; message: string; detail?: string }) => void) => {
ipcRenderer.on('main:log', (_: unknown, data: { level: string; message: string; detail?: string }) => callback(data)); ipcRenderer.on('main:log', (_: unknown, data: { level: string; message: string; detail?: string }) => callback(data));
}, },
db: {
saveSession: (session: unknown) => ipcRenderer.invoke('db:saveSession', session),
getSession: (id: string) => ipcRenderer.invoke('db:getSession', id),
getAllSessions: () => ipcRenderer.invoke('db:getAllSessions'),
deleteSession: (id: string) => ipcRenderer.invoke('db:deleteSession', id),
clearAllSessions: () => ipcRenderer.invoke('db:clearAllSessions'),
saveMessage: (msg: unknown) => ipcRenderer.invoke('db:saveMessage', msg),
getMessages: (sessionId: string) => ipcRenderer.invoke('db:getMessages', sessionId),
saveMemory: (entry: unknown) => ipcRenderer.invoke('db:saveMemory', entry),
getMemory: (id: string) => ipcRenderer.invoke('db:getMemory', id),
getAllMemories: () => ipcRenderer.invoke('db:getAllMemories'),
getMemoriesByType: (type: string) => ipcRenderer.invoke('db:getMemoriesByType', type),
deleteMemory: (id: string) => ipcRenderer.invoke('db:deleteMemory', id),
clearAllMemories: () => ipcRenderer.invoke('db:clearAllMemories'),
searchMemories: (query: string, limit?: number) => ipcRenderer.invoke('db:searchMemories', query, limit),
saveSetting: (key: string, value: unknown) => ipcRenderer.invoke('db:saveSetting', key, value),
getSetting: (key: string, defaultValue?: unknown) => ipcRenderer.invoke('db:getSetting', key, defaultValue),
saveToolCall: (tc: unknown) => ipcRenderer.invoke('db:saveToolCall', tc),
getToolCalls: (sessionId: string) => ipcRenderer.invoke('db:getToolCalls', sessionId),
saveTrace: (trace: unknown) => ipcRenderer.invoke('db:saveTrace', trace),
getTraces: (sessionId: string) => ipcRenderer.invoke('db:getTraces', sessionId),
exportSessions: () => ipcRenderer.invoke('db:exportSessions'),
importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data),
},
workspace: { workspace: {
getDir: () => ipcRenderer.invoke('workspace:getDir'), getDir: () => ipcRenderer.invoke('workspace:getDir'),
setDir: (dir: string) => ipcRenderer.invoke('workspace:setDir', dir), setDir: (dir: string) => ipcRenderer.invoke('workspace:setDir', dir),
+6 -2
View File
@@ -188,11 +188,15 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
function renderToolCallCard(tc: ToolCallRecord): string { function renderToolCallCard(tc: ToolCallRecord): string {
const icons: Record<string, string> = { const icons: Record<string, string> = {
read_file: '📄', write_file: '✏️', list_directory: '📁', read_file: '📄', write_file: '✏️', list_directory: '📁',
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻' search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
web_search: '🔍', web_fetch: '🌐', git: '🔖',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖'
}; };
const names: Record<string, string> = { const names: Record<string, string> = {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令' search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令',
web_search: '联网搜索', web_fetch: '网页抓取', git: 'Git 操作',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话'
}; };
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消' pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
+344 -120
View File
@@ -1,14 +1,24 @@
/** /**
* ChatDB - IndexedDB 封装 * ChatDB - 存储兼容
* v2: 新增 memories 存储(Agent 记忆系统) * v4.0: 桌面端走 IPC → SQLiteWeb 端仍用 IndexedDB
*/ */
import type { ChatSession, MemoryEntry } from '../types.js'; import type { ChatSession, MemoryEntry } from '../types.js';
/** 检查是否在桌面环境 */
function isDesktop(): boolean {
return !!(window as any).metonaDesktop?.isDesktop;
}
/** 桌面端 DB 桥接 */
function dbBridge() {
return (window as any).metonaDesktop?.db;
}
export class ChatDB { export class ChatDB {
private dbName: string; private dbName: string;
private version: number; private version: number;
private db: IDBDatabase | null = null; private idb: IDBDatabase | null = null;
constructor(dbName = 'metona-ollama', version = 2) { constructor(dbName = 'metona-ollama', version = 2) {
this.dbName = dbName; this.dbName = dbName;
@@ -16,15 +26,22 @@ export class ChatDB {
} }
async init(): Promise<void> { async init(): Promise<void> {
if (isDesktop()) {
// 桌面端:SQLite 已在主进程初始化,这里不需要 IndexedDB
// 但为了数据迁移,仍打开 IndexedDB(只读)
await this._openIDB();
return;
}
// Web 端:使用 IndexedDB
await this._openIDB();
}
/** 打开 IndexedDB(用于迁移或 Web 端 fallback */
private async _openIDB(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version); const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => { request.onerror = () => reject(request.error);
reject(request.error); request.onsuccess = () => { this.idb = request.result; resolve(); };
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result; const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('sessions')) { if (!db.objectStoreNames.contains('sessions')) {
@@ -46,172 +63,379 @@ export class ChatDB {
}); });
} }
private _ensureDB(): void { // ── Sessions ──
if (!this.db) throw new Error('数据库未初始化,请先调用 init()');
}
private _tx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
this._ensureDB();
const tx = this.db!.transaction(storeName, mode);
return tx.objectStore(storeName);
}
async saveSession(session: ChatSession): Promise<string> { async saveSession(session: ChatSession): Promise<string> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const store = this._tx('sessions', 'readwrite'); const row = {
const request = store.put(session); id: session.id,
request.onsuccess = () => resolve(session.id); title: session.title,
request.onerror = () => reject(request.error); model: session.model,
}); system_prompt: null,
parent_id: null,
status: 'active',
created_at: session.createdAt,
updated_at: session.updatedAt
};
await dbBridge().saveSession(row);
// 同步保存消息
for (const msg of session.messages) {
const msgRow = {
id: `${session.id}_${msg.timestamp}_${msg.role}`,
session_id: session.id,
role: msg.role,
content: msg.content || null,
thinking: msg.think || null,
images: msg.images?.length ? JSON.stringify(msg.images) : null,
tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
tool_name: null,
eval_count: msg.eval_count || null,
total_duration: msg.total_duration || null,
created_at: msg.timestamp
};
await dbBridge().saveMessage(msgRow);
}
return session.id;
}
return this._idbSaveSession(session);
} }
async getSession(id: string): Promise<ChatSession | null> { async getSession(id: string): Promise<ChatSession | null> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const store = this._tx('sessions'); const row = await dbBridge().getSession(id);
const request = store.get(id); if (!row) return null;
request.onsuccess = () => resolve(request.result || null); const msgRows = await dbBridge().getMessages(id);
request.onerror = () => reject(request.error); const messages = msgRows.map((r: any) => ({
}); role: r.role,
content: r.content || '',
timestamp: r.created_at,
think: r.thinking || undefined,
images: r.images ? JSON.parse(r.images) : undefined,
eval_count: r.eval_count || undefined,
total_duration: r.total_duration || undefined,
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined
}));
return {
id: row.id,
title: row.title,
model: row.model,
messages,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
return this._idbGetSession(id);
} }
async getAllSessions(): Promise<ChatSession[]> { async getAllSessions(): Promise<ChatSession[]> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const store = this._tx('sessions'); const rows = await dbBridge().getAllSessions();
const request = store.getAll(); const sessions: ChatSession[] = [];
request.onsuccess = () => resolve(request.result || []); for (const row of rows) {
request.onerror = () => reject(request.error); const session = await this.getSession(row.id);
}); if (session) sessions.push(session);
}
return sessions;
}
return this._idbGetAllSessions();
} }
async deleteSession(id: string): Promise<void> { async deleteSession(id: string): Promise<void> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const store = this._tx('sessions', 'readwrite'); await dbBridge().deleteSession(id);
const request = store.delete(id); return;
request.onsuccess = () => resolve(); }
request.onerror = () => reject(request.error); return this._idbDeleteSession(id);
});
} }
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const store = this._tx('sessions', 'readwrite'); await dbBridge().clearAllSessions();
const request = store.clear(); return;
request.onsuccess = () => resolve(); }
request.onerror = () => reject(request.error); return this._idbClearAll();
});
} }
async importSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> { async importSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> {
return new Promise((resolve, reject) => { if (isDesktop()) {
const tx = this.db!.transaction('sessions', 'readwrite'); const data = {
const store = tx.objectStore('sessions'); sessions: sessions.map(s => ({
let imported = 0; id: s.id, title: s.title, model: s.model, system_prompt: null,
let skipped = 0; parent_id: null, status: 'active', created_at: s.createdAt, updated_at: s.updatedAt
})),
messages: sessions.flatMap(s => s.messages.map(m => ({
id: `${s.id}_${m.timestamp}_${m.role}`,
session_id: s.id, role: m.role, content: m.content || null,
thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null,
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
tool_name: null, eval_count: m.eval_count || null,
total_duration: m.total_duration || null, created_at: m.timestamp
}))),
memories: [],
settings: [],
exportedAt: Date.now()
};
return dbBridge().importSessions(data);
}
return this._idbImportSessions(sessions);
}
async getSessionsByTimeRange(startTime: number, endTime: number): Promise<ChatSession[]> {
const all = await this.getAllSessions();
return all.filter(s => s.updatedAt >= startTime && s.updatedAt <= endTime);
}
// ── Settings ──
async saveSetting(key: string, value: unknown): Promise<void> {
if (isDesktop()) {
await dbBridge().saveSetting(key, value);
return;
}
return this._idbSaveSetting(key, value);
}
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
if (isDesktop()) {
return dbBridge().getSetting(key, defaultValue);
}
return this._idbGetSetting(key, defaultValue);
}
// ── Memories ──
async saveMemory(entry: MemoryEntry): Promise<string> {
if (isDesktop()) {
const row = {
id: entry.id,
type: entry.type,
content: entry.content,
importance: entry.importance,
tags: entry.tags?.length ? JSON.stringify(entry.tags) : null,
source: entry.source || null,
session_id: entry.sessionId || null,
use_count: entry.useCount,
embedding: entry.embedding ? JSON.stringify(entry.embedding) : null,
created_at: entry.createdAt,
updated_at: entry.updatedAt,
last_used_at: entry.lastUsedAt
};
await dbBridge().saveMemory(row);
return entry.id;
}
return this._idbSaveMemory(entry);
}
async getMemory(id: string): Promise<MemoryEntry | null> {
if (isDesktop()) {
const row = await dbBridge().getMemory(id);
return row ? this._rowToMemory(row) : null;
}
return this._idbGetMemory(id);
}
async getAllMemories(): Promise<MemoryEntry[]> {
if (isDesktop()) {
const rows = await dbBridge().getAllMemories();
return rows.map((r: any) => this._rowToMemory(r));
}
return this._idbGetAllMemories();
}
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
if (isDesktop()) {
const rows = await dbBridge().getMemoriesByType(type);
return rows.map((r: any) => this._rowToMemory(r));
}
return this._idbGetMemoriesByType(type);
}
async deleteMemory(id: string): Promise<void> {
if (isDesktop()) {
await dbBridge().deleteMemory(id);
return;
}
return this._idbDeleteMemory(id);
}
async clearAllMemories(): Promise<void> {
if (isDesktop()) {
await dbBridge().clearAllMemories();
return;
}
return this._idbClearAllMemories();
}
// ── Helpers ──
private _rowToMemory(row: any): MemoryEntry {
return {
id: row.id,
type: row.type,
content: row.content,
importance: row.importance,
tags: row.tags ? JSON.parse(row.tags) : [],
source: row.source || undefined,
sessionId: row.session_id || undefined,
useCount: row.use_count,
embedding: row.embedding ? JSON.parse(row.embedding) : undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastUsedAt: row.last_used_at
};
}
// ═══════════════════════════════════════════
// IndexedDB fallback methods (Web 端兼容)
// ═══════════════════════════════════════════
private _ensureIDB(): void {
if (!this.idb) throw new Error('IndexedDB 未初始化');
}
private _idbTx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
this._ensureIDB();
return this.idb!.transaction(storeName, mode).objectStore(storeName);
}
private async _idbSaveSession(session: ChatSession): Promise<string> {
return new Promise((resolve, reject) => {
const store = this._idbTx('sessions', 'readwrite');
const req = store.put(session);
req.onsuccess = () => resolve(session.id);
req.onerror = () => reject(req.error);
});
}
private async _idbGetSession(id: string): Promise<ChatSession | null> {
return new Promise((resolve, reject) => {
const store = this._idbTx('sessions');
const req = store.get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
private async _idbGetAllSessions(): Promise<ChatSession[]> {
return new Promise((resolve, reject) => {
const store = this._idbTx('sessions');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
private async _idbDeleteSession(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const store = this._idbTx('sessions', 'readwrite');
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
private async _idbClearAll(): Promise<void> {
return new Promise((resolve, reject) => {
const store = this._idbTx('sessions', 'readwrite');
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
private async _idbImportSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> {
return new Promise((resolve, reject) => {
const tx = this.idb!.transaction('sessions', 'readwrite');
const store = tx.objectStore('sessions');
let imported = 0; let skipped = 0;
tx.oncomplete = () => resolve({ imported, skipped }); tx.oncomplete = () => resolve({ imported, skipped });
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
for (const session of sessions) { for (const session of sessions) {
if (!session.id || !Array.isArray(session.messages)) { if (!session.id || !Array.isArray(session.messages)) { skipped++; continue; }
skipped++; const getReq = store.get(session.id);
continue; getReq.onsuccess = () => {
} if (getReq.result) { skipped++; } else { store.put(session); imported++; }
const getRequest = store.get(session.id);
getRequest.onsuccess = () => {
if (getRequest.result) {
skipped++;
} else {
store.put(session);
imported++;
}
}; };
} }
}); });
} }
async getSessionsByTimeRange(startTime: number, endTime: number): Promise<ChatSession[]> { private async _idbSaveSetting(key: string, value: unknown): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('sessions'); const store = this._idbTx('settings', 'readwrite');
const index = store.index('updatedAt'); const req = store.put({ key, value });
const range = IDBKeyRange.bound(startTime, endTime); req.onsuccess = () => resolve();
const request = index.getAll(range); req.onerror = () => reject(req.error);
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
}); });
} }
async saveSetting(key: string, value: unknown): Promise<void> { private async _idbGetSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('settings', 'readwrite'); const store = this._idbTx('settings');
const request = store.put({ key, value }); const req = store.get(key);
request.onsuccess = () => resolve(); req.onsuccess = () => resolve(req.result ? req.result.value : defaultValue);
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> { private async _idbSaveMemory(entry: MemoryEntry): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('settings'); const store = this._idbTx('memories', 'readwrite');
const request = store.get(key); const req = store.put(entry);
request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue); req.onsuccess = () => resolve(entry.id);
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
// ── Agent 记忆系统 CRUD ── private async _idbGetMemory(id: string): Promise<MemoryEntry | null> {
async saveMemory(entry: MemoryEntry): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite'); const store = this._idbTx('memories');
const request = store.put(entry); const req = store.get(id);
request.onsuccess = () => resolve(entry.id); req.onsuccess = () => resolve(req.result || null);
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
async getMemory(id: string): Promise<MemoryEntry | null> { private async _idbGetAllMemories(): Promise<MemoryEntry[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('memories'); const store = this._idbTx('memories');
const request = store.get(id); const req = store.getAll();
request.onsuccess = () => resolve(request.result || null); req.onsuccess = () => resolve(req.result || []);
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
async getAllMemories(): Promise<MemoryEntry[]> { private async _idbGetMemoriesByType(type: string): Promise<MemoryEntry[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('memories'); const store = this._idbTx('memories');
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
return new Promise((resolve, reject) => {
const store = this._tx('memories');
const index = store.index('type'); const index = store.index('type');
const request = index.getAll(type); const req = index.getAll(type);
request.onsuccess = () => resolve(request.result || []); req.onsuccess = () => resolve(req.result || []);
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
async deleteMemory(id: string): Promise<void> { private async _idbDeleteMemory(id: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite'); const store = this._idbTx('memories', 'readwrite');
const request = store.delete(id); const req = store.delete(id);
request.onsuccess = () => resolve(); req.onsuccess = () => resolve();
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
async clearAllMemories(): Promise<void> { private async _idbClearAllMemories(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const store = this._tx('memories', 'readwrite'); const store = this._idbTx('memories', 'readwrite');
const request = store.clear(); const req = store.clear();
request.onsuccess = () => resolve(); req.onsuccess = () => resolve();
request.onerror = () => reject(request.error); req.onerror = () => reject(req.error);
}); });
} }
/** 获取原始 IndexedDB 实例(用于数据迁移) */
getRawIDB(): IDBDatabase | null {
return this.idb;
}
} }
+1 -1
View File
@@ -27,7 +27,7 @@
<div class="header-left"> <div class="header-left">
<span class="logo">🦙</span> <span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v3.3.4</span> <span class="app-version">v4.0.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
+117 -1
View File
@@ -29,6 +29,120 @@ import { initLogPanel, addLog } from './services/log-service.js';
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js'; import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
import type { ChatSession } from './types.js'; import type { ChatSession } from './types.js';
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.isDesktop || !bridge?.db) {
logInit('非桌面环境,跳过迁移');
return;
}
// 检查是否已迁移
const migrated = await bridge.db.getSetting('_indexeddb_migrated', false);
if (migrated) {
logInit('SQLite 数据库已连接(已迁移)');
return;
}
// 检查 IndexedDB 中是否有数据
const idb = db.getRawIDB();
if (!idb) {
logInit('SQLite 数据库已连接(无旧数据)');
return;
}
// 读取 IndexedDB 数据
try {
const sessions: ChatSession[] = await new Promise((resolve, reject) => {
const tx = idb.transaction('sessions', 'readonly');
const store = tx.objectStore('sessions');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const memories: any[] = await new Promise((resolve, reject) => {
const tx = idb.transaction('memories', 'readonly');
const store = tx.objectStore('memories');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
const settings: any[] = await new Promise((resolve, reject) => {
const tx = idb.transaction('settings', 'readonly');
const store = tx.objectStore('settings');
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
if (sessions.length === 0 && memories.length === 0 && settings.length === 0) {
logInit('SQLite 数据库已连接(无旧数据)');
await bridge.db.saveSetting('_indexeddb_migrated', true);
return;
}
logInit(`发现旧数据: ${sessions.length} 会话, ${memories.length} 记忆, ${settings.length} 设置`);
// 构造导入数据
const importData = {
sessions: sessions.map(s => ({
id: s.id,
title: s.title,
model: s.model,
system_prompt: null as string | null,
parent_id: null as string | null,
status: 'active',
created_at: s.createdAt,
updated_at: s.updatedAt
})),
messages: sessions.flatMap(s =>
s.messages.map((m: any, idx: number) => ({
id: `${s.id}_msg_${idx}_${m.timestamp}`,
session_id: s.id,
role: m.role,
content: m.content || null,
thinking: m.think || null,
images: m.images?.length ? JSON.stringify(m.images) : null,
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
tool_name: null,
eval_count: m.eval_count || null,
total_duration: m.total_duration || null,
created_at: m.timestamp || Date.now()
}))
),
memories: memories.map((m: any) => ({
id: m.id,
type: m.type,
content: m.content,
importance: m.importance || 5,
tags: m.tags?.length ? JSON.stringify(m.tags) : null,
source: m.source || null,
session_id: m.sessionId || null,
use_count: m.useCount || 0,
embedding: m.embedding ? JSON.stringify(m.embedding) : null,
created_at: m.createdAt,
updated_at: m.updatedAt,
last_used_at: m.lastUsedAt
})),
settings: settings.map((s: any) => ({ key: s.key, value: s.value })),
exportedAt: Date.now()
};
const result = await bridge.db.importSessions(importData);
logInit(`迁移完成: 导入 ${result.imported} 个会话, 跳过 ${result.skipped}`);
// 标记迁移完成
await bridge.db.saveSetting('_indexeddb_migrated', true);
logSuccess('数据迁移完成');
} catch (err) {
logError('数据迁移失败', (err as Error).message);
// 迁移失败不阻塞启动
}
}
function initHelpModal(): void { function initHelpModal(): void {
const helpModal = document.querySelector('#helpModal') as HTMLElement; const helpModal = document.querySelector('#helpModal') as HTMLElement;
document.querySelector('#btnHelp')!.addEventListener('click', () => { document.querySelector('#btnHelp')!.addEventListener('click', () => {
@@ -176,7 +290,9 @@ async function init(): Promise<void> {
db = new ChatDB(); db = new ChatDB();
await db.init(); await db.init();
state.set(KEYS.DB, db); state.set(KEYS.DB, db);
logInit('IndexedDB 已连接');
// v4.0 数据迁移:检测 IndexedDB 是否有旧数据需要迁移到 SQLite
await migrateIndexedDBToSQLite(db);
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434'); const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
api = new OllamaAPI(serverUrl); api = new OllamaAPI(serverUrl);
+206 -135
View File
@@ -1,6 +1,7 @@
/** /**
* Agent Engine - Agent Loop 核心引擎 * Agent Engine - ReAct Agent Loop 核心引擎 (v4.0)
* 管理带工具调用的流式对话循环 * ReAct 模式: Thought → Action → Observation → Reflection
* 支持任务复杂度评估、自动规划、错误重试、执行轨迹记录
*/ */
import { OllamaAPI } from '../api/ollama.js'; import { OllamaAPI } from '../api/ollama.js';
@@ -12,35 +13,79 @@ import {
} from './tool-registry.js'; } from './tool-registry.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js'; import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logAgentLoop, logModelResponse } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js';
import type { import type {
OllamaMessage, OllamaMessage,
OllamaStreamChunk, OllamaStreamChunk,
ToolCall, ToolCall,
ToolResult, ToolResult,
ToolCallRecord ToolCallRecord,
TraceEntry
} from '../types.js'; } from '../types.js';
const MAX_LOOPS = 10; const MAX_LOOPS = 15;
const MAX_LOOP_TIME = 300000; // 5 分钟总超时 const MAX_LOOP_TIME = 600000; // 10 分钟总超时
const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒 const TOOL_EXEC_TIMEOUT = 30000; // 工具执行超时 30 秒
const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟 const STREAM_TIMEOUT = 120000; // 单次流式调用超时 2 分钟
const MAX_RETRIES = 2; // 工具错误自动重试次数
const REACT_SYSTEM_PROMPT = `你是一个具备 ReActReasoning + Acting)能力的 AI 助手。你必须严格按照以下格式与用户交互:
## ReAct 输出格式
每次响应时,你必须按照以下结构思考和行动:
**Thought:** [你对当前任务的分析和推理。思考用户需要什么、当前有哪些信息、下一步应该做什么。]
**Action:** [如果需要使用工具,选择一个工具并说明原因。如果不需要工具,直接给出最终答案。]
**Action Input:** [工具的参数,必须是合法 JSON 格式]
然后等待 Observation(工具返回的结果)。
收到 Observation 后:
**Thought:** [基于工具结果的进一步推理。结果是否满足需求?是否需要更多步骤?]
**Action:** [下一步行动,或者 "Final Answer" 如果任务已完成]
**Action Input:** [下一步工具参数,或者最终答案]
## 任务复杂度评估
在开始时评估任务:
- **简单**:直接回答(1 步),不需要工具或仅需 1 个工具调用
- **中等**:需要 2-4 步,可能需要多个工具协作
- **复杂**:需要 5+ 步,需要规划和分解
对于复杂任务,先输出一个简短的执行计划,再开始 ReAct 循环。
## 错误处理
- 如果工具返回错误,分析原因并尝试不同方法
- 最多重试 2 次,如果仍然失败,向用户说明情况
- 不要反复调用相同参数的相同工具
## 最终答案
当任务完成时,输出 **Final Answer:** 后面跟你的最终回答。回答应该:
- 汇总所有执行步骤和结果
- 直接回答用户的问题
- 如果有重要发现,明确指出`;
const TOOL_USAGE_GUIDE = `【工具使用规则】 const TOOL_USAGE_GUIDE = `【工具使用规则】
1. 路径上下文:当用户说"修改这个文件"、"删除它"、"查看里面"等未指定路径时,必须从本次对话中最近的工具调用结果中提取路径。例如用户之前让你读取了 /home/user/project/config.json,后续说"修改配置"就应继续使用该路径,而不是推测新路径。 1. 路径上下文:当用户说"修改这个文件"、"删除它"、"查看里面"等未指定路径时,必须从本次对话中最近的工具调用结果中提取路径。
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。 2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。 3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。 4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
5. 当用户要求执行命令时,使用 run_command 工具执行。命令通过工作空间终端实时执行并显示,执行完成后将 stdout/stderr 结果告知用户。 5. 当用户要求执行命令时,使用 run_command 工具执行。
6. 联网搜索联动:当用户询问需要最新信息的问题时(如版本号、新闻、价格等),先用 web_search 搜索,然后必须从搜索结果中选择最相关的 1-3 个 URL,用 web_fetch 抓取页面详细内容获取准确数据。不要仅凭搜索摘要回答——摘要往往不完整,必须抓取原文才能给出准确答案 6. 联网搜索联动:当用户询问需要最新信息的问题时,先用 web_search 搜索,然后必须从搜索结果中选择最相关的 1-3 个 URL,用 web_fetch 抓取页面详细内容。
7. 主动使用工具:当你不确定答案、信息可能已过时、或需要查阅实时数据时,主动调用工具而不是凭记忆猜测。以下场景必须使用工具: 7. 主动使用工具:当你不确定答案、信息可能已过时、或需要查阅实时数据时,主动调用工具而不是凭记忆猜测。
- 用户问"最新版本"、"现在多少钱"、"最近有什么新闻" → web_search + web_fetch 8. 严禁重复调用:如果本次对话中已经成功调用过某个工具并获得了结果,不要再次调用相同工具和相同参数。
- 用户让你查看/修改/分析文件 → read_file / list_directory / search_files 9. 记忆工具:你可以使用 memory_search 搜索过去的记忆,使用 memory_add 添加新的重要记忆。
- 用户问项目状态、代码内容 → git status / read_file 10. 会话工具:你可以使用 session_list 列出历史会话,使用 session_read 读取历史会话内容。`;
- 用户让你执行操作 → run_command / write_file 等
宁可多调一次工具,也不要给出过时或编造的信息。
8. 严禁重复调用:如果本次对话中已经成功调用过某个工具并获得了结果,不要再次调用相同工具和相同参数。直接使用已有结果继续工作。例如:已经 web_fetch 抓取了页面内容,下一步应直接处理该内容(如 write_file 保存),而不是重新抓取。`;
/** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */ /** 工具调用缓存:key = toolName + JSON(args),用于避免重复调用 */
const toolResultCache = new Map<string, ToolResult>(); const toolResultCache = new Map<string, ToolResult>();
@@ -48,7 +93,6 @@ const toolResultCache = new Map<string, ToolResult>();
/** 生成工具调用缓存 key */ /** 生成工具调用缓存 key */
function getToolCacheKey(name: string, args: Record<string, unknown>): string { function getToolCacheKey(name: string, args: Record<string, unknown>): string {
try { try {
// 对 arguments 排序 key 以保证一致性
return name + '::' + JSON.stringify(args, Object.keys(args).sort()); return name + '::' + JSON.stringify(args, Object.keys(args).sort());
} catch { } catch {
return name + '::' + String(args); return name + '::' + String(args);
@@ -56,10 +100,10 @@ function getToolCacheKey(name: string, args: Record<string, unknown>): string {
} }
/** 检测当前轮次是否存在重复工具调用 */ /** 检测当前轮次是否存在重复工具调用 */
function isDuplicateCall(call: ToolCall, loopCalls: ToolCall[]): boolean { function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
const callKey = getToolCacheKey(call.function.name, call.function.arguments); const callKey = getToolCacheKey(call.function.name, call.function.arguments);
return loopCalls.some((prev, idx) => { return allCalls.some((prev, idx) => {
if (idx === loopCalls.length - 1) return false; // 跳过自身 if (idx === allCalls.length - 1) return false;
return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey; return getToolCacheKey(prev.function.name, prev.function.arguments) === callKey;
}); });
} }
@@ -74,6 +118,26 @@ export interface AgentCallbacks {
onConfirmTool: (call: ToolCall) => Promise<boolean>; onConfirmTool: (call: ToolCall) => Promise<boolean>;
} }
/** 保存执行轨迹到 SQLite */
async function saveTrace(trace: Omit<TraceEntry, 'id' | 'createdAt'>): Promise<void> {
try {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db) return;
const entry = {
id: `trace_${generateId()}`,
session_id: trace.sessionId,
step_index: trace.stepIndex,
thought: trace.thought,
action: trace.action,
action_input: trace.actionInput,
observation: trace.observation,
loop_count: trace.loopCount,
created_at: trace.createdAt
};
await bridge.db.saveTrace(entry);
} catch { /* 不阻塞主流程 */ }
}
export async function runAgentLoop( export async function runAgentLoop(
userContent: string, userContent: string,
images: string[], images: string[],
@@ -82,6 +146,8 @@ export async function runAgentLoop(
): Promise<void> { ): Promise<void> {
const api = state.get<OllamaAPI>(KEYS.API); const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_defaultModel', ''); const model = state.get<string>('_defaultModel', '');
const currentSession = state.get(KEYS.CURRENT_SESSION);
const sessionId = currentSession?.id || 'unknown';
if (!api || !model) { if (!api || !model) {
showToast('请先选择模型', 'error'); showToast('请先选择模型', 'error');
@@ -93,12 +159,8 @@ export async function runAgentLoop(
// 检查模型是否支持 Tool Calling // 检查模型是否支持 Tool Calling
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false); const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
if (!modelSupportsTools) {
logWarn(`模型 ${model} 不支持 Tool Calling`, 'Agent Loop 可能不稳定');
}
const messages: OllamaMessage[] = []; const messages: OllamaMessage[] = [];
let systemPromptParts: string[] = []; let systemPromptParts: string[] = [];
// 注入记忆上下文 // 注入记忆上下文
@@ -121,12 +183,14 @@ export async function runAgentLoop(
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`); 文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
} }
if (systemPromptParts.length > 0) { // 组合 system prompt
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') + '\n\n' + TOOL_USAGE_GUIDE }); const fullSystemPrompt = systemPromptParts.length > 0
} else { ? systemPromptParts.join('\n\n') + '\n\n' + REACT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE
messages.push({ role: 'system', content: TOOL_USAGE_GUIDE }); : REACT_SYSTEM_PROMPT + '\n\n' + TOOL_USAGE_GUIDE;
}
messages.push({ role: 'system', content: fullSystemPrompt });
// 添加历史消息(使用 context-manager 构建)
for (const msg of historyMessages) { for (const msg of historyMessages) {
messages.push({ messages.push({
role: msg.role as 'user' | 'assistant', role: msg.role as 'user' | 'assistant',
@@ -135,6 +199,7 @@ export async function runAgentLoop(
}); });
} }
// 用户消息
const userMsg: OllamaMessage = { const userMsg: OllamaMessage = {
role: 'user', role: 'user',
content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''), content: userContent || (images?.length ? (images.length > 1 ? `请分析这 ${images.length} 张图片` : '请分析这张图片') : ''),
@@ -145,18 +210,19 @@ export async function runAgentLoop(
const tools = getEnabledToolDefinitions(); const tools = getEnabledToolDefinitions();
const useTools = tools.length > 0; const useTools = tools.length > 0;
logInfo(`Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`); logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}`);
let loopCount = 0; let loopCount = 0;
const allToolRecords: ToolCallRecord[] = []; const allToolRecords: ToolCallRecord[] = [];
const loopStartTime = Date.now(); const loopStartTime = Date.now();
let content = ''; let content = '';
let totalEvalCount = 0; let totalEvalCount = 0;
/** 记录前一轮的工具调用 key,用于跨轮去重 */
let prevLoopToolKeys: string[] = []; let prevLoopToolKeys: string[] = [];
/** 每轮工具调用计数(用于去重) */
let allCallsThisLoop: ToolCall[] = [];
const makeStats = () => { const makeStats = () => {
const totalDuration = (Date.now() - loopStartTime) * 1e6; // 转为纳秒 const totalDuration = (Date.now() - loopStartTime) * 1e6;
return { eval_count: totalEvalCount || undefined, total_duration: totalDuration }; return { eval_count: totalEvalCount || undefined, total_duration: totalDuration };
}; };
@@ -165,14 +231,14 @@ export async function runAgentLoop(
// 全局超时检查 // 全局超时检查
if (Date.now() - loopStartTime > MAX_LOOP_TIME) { if (Date.now() - loopStartTime > MAX_LOOP_TIME) {
logWarn('Agent Loop 达到最大运行时间(5分钟),自动停止'); logWarn('ReAct Agent Loop 达到最大运行时间(10分钟),自动停止');
callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords, makeStats()); callbacks.onDone(content || '(达到最大运行时间限制)', allToolRecords, makeStats());
return; return;
} }
// 检查是否已中止 // 检查是否已中止
if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) { if (state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
logInfo('Agent Loop 已中止'); logInfo('ReAct Agent Loop 已中止');
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
} }
@@ -182,6 +248,7 @@ export async function runAgentLoop(
let thinking = ''; let thinking = '';
content = ''; content = '';
const toolCalls: ToolCall[] = []; const toolCalls: ToolCall[] = [];
allCallsThisLoop = [];
const abortController = new AbortController(); const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController); state.set(KEYS.ABORT_CONTROLLER, abortController);
@@ -205,10 +272,6 @@ export async function runAgentLoop(
if (chunk.message?.thinking) { if (chunk.message?.thinking) {
thinking += chunk.message.thinking; thinking += chunk.message.thinking;
callbacks.onThinking(thinking); callbacks.onThinking(thinking);
// 每收到 200 字符记录一次思考日志
if (thinking.length % 200 < 10) {
logThink(thinking);
}
} }
if (chunk.message?.content) { if (chunk.message?.content) {
content += chunk.message.content; content += chunk.message.content;
@@ -218,7 +281,6 @@ export async function runAgentLoop(
if (chunk.message?.tool_calls?.length) { if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) { for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) { if (tc.function?.name) {
// 新的工具调用
toolCalls.push({ toolCalls.push({
type: 'function', type: 'function',
function: { function: {
@@ -227,7 +289,6 @@ export async function runAgentLoop(
} }
}); });
} else if (toolCalls.length > 0) { } else if (toolCalls.length > 0) {
// 增量 arguments 追加到当前最后一个 tool_call
const last = toolCalls[toolCalls.length - 1]; const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') { if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
Object.assign(last.function.arguments, tc.function.arguments); Object.assign(last.function.arguments, tc.function.arguments);
@@ -246,24 +307,15 @@ export async function runAgentLoop(
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
logInfo('流式调用已中止'); logInfo('流式调用已中止');
if (content || thinking) { if (content || thinking) {
messages.push({ messages.push({ role: 'assistant', content, ...(thinking && { thinking }) });
role: 'assistant',
content,
...(thinking && { thinking })
});
} }
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
} }
// 超时错误 — 保留已生成的内容
if ((err as Error).message.includes('超时')) { if ((err as Error).message.includes('超时')) {
logError('流式调用超时2分钟无数据)', (err as Error).message); logError('流式调用超时', (err as Error).message);
if (content || thinking) { if (content || thinking) {
messages.push({ messages.push({ role: 'assistant', content: content || '(模型响应超时)', ...(thinking && { thinking }) });
role: 'assistant',
content: content || '(模型响应超时)',
...(thinking && { thinking })
});
} }
callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); callbacks.onDone(content || '(模型响应超时,已自动中断)', allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
@@ -271,6 +323,11 @@ export async function runAgentLoop(
throw err; throw err;
} }
// 提取 ReAct 思考过程
const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i);
const thought = thoughtMatch ? thoughtMatch[1].trim() : '';
// 保存 assistant 消息
const assistantMsg: OllamaMessage = { const assistantMsg: OllamaMessage = {
role: 'assistant', role: 'assistant',
content, content,
@@ -278,30 +335,54 @@ export async function runAgentLoop(
}; };
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls; assistantMsg.tool_calls = toolCalls;
// 官方示例要求:tool_calls 时同时保留 content 和 thinking
// 确保 content 不为空(模型可能在调用工具前说了话)
} }
messages.push(assistantMsg); messages.push(assistantMsg);
logModelResponse(content.length, toolCalls.length); logModelResponse(content.length, toolCalls.length);
// 检查是否是 Final Answer(没有工具调用且包含 Final Answer
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:');
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
logInfo('无工具调用,Agent Loop 结束'); logInfo('无工具调用,ReAct Agent Loop 结束');
// 自动提取记忆
if (isMemoryEnabled() && messages.length >= 4) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
await extractMemoriesFromConversation(
messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
} catch { /* 不阻塞 */ }
}
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
} }
// 跨轮次完整重复检测:如果本轮所有工具调用与上一轮完全相同,终止循环 // 跨轮次完整重复检测
const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort(); const currentLoopKeys = toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
const currentKeysStr = JSON.stringify(currentLoopKeys); const currentKeysStr = JSON.stringify(currentLoopKeys);
const prevKeysStr = JSON.stringify([...prevLoopToolKeys].sort()); const prevKeysStr = JSON.stringify([...prevLoopToolKeys].sort());
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) { if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
logWarn('检测到连续两轮工具调用完全相同,终止 Agent Loop', currentKeysStr.slice(0, 200)); logWarn('检测到连续两轮工具调用完全相同,终止 ReAct Loop', currentKeysStr.slice(0, 200));
callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats()); callbacks.onDone(content || '(检测到重复工具调用,已自动停止)', allToolRecords, makeStats());
return; return;
} }
prevLoopToolKeys = currentLoopKeys; prevLoopToolKeys = currentLoopKeys;
// 记录 ReAct 轨迹
const traceStep = {
sessionId,
stepIndex: loopCount,
thought: thought || content.slice(0, 200),
action: toolCalls.map(t => t.function.name).join(', '),
actionInput: JSON.stringify(toolCalls.map(t => t.function.arguments)),
observation: '',
loopCount,
createdAt: Date.now()
};
for (const call of toolCalls) { for (const call of toolCalls) {
// 检查是否已中止 // 检查是否已中止
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@@ -311,30 +392,22 @@ export async function runAgentLoop(
// 同一轮内重复调用检测 // 同一轮内重复调用检测
if (isDuplicateCall(call, toolCalls)) { if (isDuplicateCall(call, toolCalls)) {
logWarn(`跳过重复工具调用: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200)); logWarn(`跳过重复工具调用: ${call.function.name}`);
const cachedKey = getToolCacheKey(call.function.name, call.function.arguments); const cachedKey = getToolCacheKey(call.function.name, call.function.arguments);
const cached = toolResultCache.get(cachedKey); const cached = toolResultCache.get(cachedKey);
if (cached) { if (cached) {
messages.push({ messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(cached) });
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(cached)
});
callbacks.onToolCallResult(call.function.name, cached, call); callbacks.onToolCallResult(call.function.name, cached, call);
} }
continue; continue;
} }
// 跨轮次缓存命中检测 // 跨轮次缓存命中
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments); const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
const cachedResult = toolResultCache.get(cacheKey); const cachedResult = toolResultCache.get(cacheKey);
if (cachedResult) { if (cachedResult) {
logInfo(`工具缓存命中: ${call.function.name}`, JSON.stringify(call.function.arguments).slice(0, 200)); logInfo(`工具缓存命中: ${call.function.name}`);
messages.push({ messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(cachedResult) });
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify(cachedResult)
});
callbacks.onToolCallResult(call.function.name, cachedResult, call); callbacks.onToolCallResult(call.function.name, cachedResult, call);
continue; continue;
} }
@@ -344,13 +417,12 @@ export async function runAgentLoop(
if (needsConfirmation(call.function.name)) { if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call); const confirmed = await callbacks.onConfirmTool(call);
// 确认后再次检查是否已中止
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats()); callbacks.onDone(content, allToolRecords.length > 0 ? allToolRecords : undefined, makeStats());
return; return;
} }
if (!confirmed) { if (!confirmed) {
logWarn(`工具取消: ${call.function.name}`, '用户取消了操作'); logWarn(`工具取消: ${call.function.name}`);
const record: ToolCallRecord = { const record: ToolCallRecord = {
name: call.function.name, name: call.function.name,
arguments: call.function.arguments, arguments: call.function.arguments,
@@ -359,77 +431,76 @@ export async function runAgentLoop(
timestamp: Date.now() timestamp: Date.now()
}; };
allToolRecords.push(record); allToolRecords.push(record);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: '用户取消了操作' }) });
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: '用户取消了操作' })
});
callbacks.onToolCallError(call.function.name, '用户取消', call); callbacks.onToolCallError(call.function.name, '用户取消', call);
continue; continue;
} }
} }
try { // 工具执行 + 自动重试
// 工具执行(run_command 无超时,其他工具 30 秒超时) let lastError = '';
logInfo(`执行工具: ${call.function.name}`, JSON.stringify(call.function.arguments)); for (let retry = 0; retry <= MAX_RETRIES; retry++) {
let result: ToolResult; try {
if (call.function.name === 'run_command') { let result: ToolResult;
// run_command 通过 workspace IPC 执行,无超时限制 if (call.function.name === 'run_command') {
result = await executeTool(call.function.name, call.function.arguments); result = await executeTool(call.function.name, call.function.arguments);
} else { } else {
// 其他工具保持 30 秒超时保护 result = await Promise.race([
result = await Promise.race([ executeTool(call.function.name, call.function.arguments).catch(err =>
executeTool(call.function.name, call.function.arguments).catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult
({ success: false, error: err?.message || String(err) }) as ToolResult ),
), new Promise<ToolResult>((_, reject) =>
new Promise<ToolResult>((_, reject) => setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT) )
) ]);
]); }
}
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result,
status: result.success ? 'success' : 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({ // 成功
role: 'tool', const record: ToolCallRecord = {
tool_name: call.function.name, name: call.function.name,
content: JSON.stringify(result) arguments: call.function.arguments,
}); result,
// 缓存成功结果(非 run_command,因为命令执行可能有副作用) status: result.success ? 'success' : 'error',
if (result.success && call.function.name !== 'run_command') { timestamp: Date.now()
toolResultCache.set(cacheKey, result); };
allToolRecords.push(record);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify(result) });
if (result.success && call.function.name !== 'run_command') {
toolResultCache.set(cacheKey, result);
}
callbacks.onToolCallResult(call.function.name, result, call);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
break; // 成功,退出重试循环
} catch (err) {
lastError = (err as Error).message;
if (retry < MAX_RETRIES) {
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
await new Promise(r => setTimeout(r, 500)); // 短暂延迟后重试
continue;
}
// 最终失败
logError(`工具执行失败: ${call.function.name}`, lastError);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: lastError },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({ role: 'tool', tool_name: call.function.name, content: JSON.stringify({ success: false, error: lastError }) });
callbacks.onToolCallError(call.function.name, lastError, call);
} }
callbacks.onToolCallResult(call.function.name, result, call);
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
} catch (err) {
const errorMsg = (err as Error).message;
logError(`工具执行失败: ${call.function.name}`, errorMsg);
const record: ToolCallRecord = {
name: call.function.name,
arguments: call.function.arguments,
result: { success: false, error: errorMsg },
status: 'error',
timestamp: Date.now()
};
allToolRecords.push(record);
messages.push({
role: 'tool',
tool_name: call.function.name,
content: JSON.stringify({ success: false, error: errorMsg })
});
callbacks.onToolCallError(call.function.name, errorMsg, call);
} }
} }
// 保存本轮轨迹
traceStep.observation = allToolRecords.slice(-toolCalls.length).map(r =>
`${r.name}: ${r.result?.success ? 'success' : 'error'}`
).join('; ');
saveTrace(traceStep);
} }
logWarn('Agent Loop 达到最大工具调用次数限制'); logWarn('ReAct Agent Loop 达到最大工具调用次数限制');
callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats()); callbacks.onDone(content || '(达到最大工具调用次数限制)', allToolRecords, makeStats());
} }
+175
View File
@@ -0,0 +1,175 @@
/**
* Context Manager - 智能上下文管理 (v4.0)
* 三层策略:滑动窗口 + 消息摘要压缩 + 记忆注入
*/
import type { OllamaMessage } from '../types.js';
import { logInfo, logWarn } from './log-service.js';
/** 粗略估算 token 数 */
export function estimateTokens(text: string): number {
if (!text) return 0;
// 中文按 1.5 字/token,英文按 4 字符/token
let chineseChars = 0;
let otherChars = 0;
for (const ch of text) {
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
else otherChars++;
}
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
}
export interface ContextBuildOptions {
/** 滑动窗口大小(最近 N 条消息完整保留) */
windowSize?: number;
/** 每 N 条更早的消息压缩为一段摘要 */
summaryBatchSize?: number;
/** 最大 token 数限制 */
maxTokens?: number;
/** 系统 prompt 注入的记忆上下文 */
memoryContext?: string;
/** 工作空间上下文 */
workspaceContext?: string;
}
const DEFAULT_OPTIONS: Required<ContextBuildOptions> = {
windowSize: 20,
summaryBatchSize: 20,
maxTokens: 24576,
memoryContext: '',
workspaceContext: ''
};
/**
* 构建发送给模型的 messages
* 三层策略:
* a. 滑动窗口:最近 N 条消息完整保留
* b. 更早的消息:每 N 条压缩为一段摘要
* c. 系统 prompt 注入记忆上下文
*/
export function buildContext(
allMessages: OllamaMessage[],
options: ContextBuildOptions = {}
): OllamaMessage[] {
const opts = { ...DEFAULT_OPTIONS, ...options };
const result: OllamaMessage[] = [];
// 系统 prompt
let systemContent = '';
if (opts.memoryContext) {
systemContent += opts.memoryContext + '\n\n';
}
if (opts.workspaceContext) {
systemContent += opts.workspaceContext + '\n\n';
}
// 提取已有的 system 消息
const existingSystem = allMessages.find(m => m.role === 'system');
if (existingSystem) {
systemContent += existingSystem.content;
}
if (systemContent.trim()) {
result.push({ role: 'system', content: systemContent.trim() });
}
// 非 system 消息
const nonSystemMessages = allMessages.filter(m => m.role !== 'system');
if (nonSystemMessages.length <= opts.windowSize) {
// 消息不多,全部保留
result.push(...nonSystemMessages);
return result;
}
// 滑动窗口:最近 N 条
const recentMessages = nonSystemMessages.slice(-opts.windowSize);
// 更早的消息:压缩为摘要
const olderMessages = nonSystemMessages.slice(0, -opts.windowSize);
const summaries = summarizeOlderMessages(olderMessages, opts.summaryBatchSize);
result.push(...summaries, ...recentMessages);
// Token 估算和裁剪
const trimmed = trimByTokenLimit(result, opts.maxTokens);
logInfo(`上下文构建: ${nonSystemMessages.length} 条消息 → ${trimmed.length} 条 (窗口: ${opts.windowSize})`,
`估算 tokens: ${estimateTokens(trimmed.map(m => m.content).join(''))}`);
return trimmed;
}
/**
* 将较早的消息每 batchSize 条压缩为一段摘要
*/
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
const summaries: OllamaMessage[] = [];
for (let i = 0; i < messages.length; i += batchSize) {
const batch = messages.slice(i, i + batchSize);
const summary = createQuickSummary(batch);
summaries.push({
role: 'system',
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`
});
}
return summaries;
}
/**
* 快速摘要(不调用模型,直接提取关键信息)
*/
function createQuickSummary(messages: OllamaMessage[]): string {
const parts: string[] = [];
for (const msg of messages) {
const role = msg.role === 'user' ? '用户' : 'AI';
const content = msg.content || '';
// 取前 100 字符作为摘要
const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
if (preview.trim()) {
parts.push(`${role}: ${preview}`);
}
// 如果有工具调用,记录工具名
if (msg.tool_calls?.length) {
const toolNames = msg.tool_calls.map(t => t.function.name).join(', ');
parts.push(` [工具: ${toolNames}]`);
}
}
return parts.join('\n');
}
/**
* 根据 token 限制裁剪消息
*/
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
let totalTokens = 0;
const result: OllamaMessage[] = [];
// 从后往前添加(优先保留最近的消息)
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const msgTokens = estimateTokens(msg.content || '') +
(msg.images ? msg.images.length * 100 : 0) +
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
// system 消息始终保留
if (msg.role === 'system') {
totalTokens += msgTokens;
result.unshift(msg);
continue;
}
if (totalTokens + msgTokens > maxTokens && result.length > 2) {
break; // 已到限制,保留至少 system + 1 条消息
}
totalTokens += msgTokens;
result.unshift(msg);
}
return result;
}
+146 -43
View File
@@ -350,6 +350,71 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
} }
} }
} }
},
// ══════════════════════════════════════════════
// v4.0 新增工具:记忆和会话管理
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'memory_search',
description: 'Search agent memories by semantic similarity or keywords. Returns relevant memories about the user, preferences, rules, and past conversations.',
parameters: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', description: 'Search query for memories.' },
limit: { type: 'integer', description: 'Max results to return. Default: 8.' },
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Filter by memory type.' }
}
}
}
},
{
type: 'function',
function: {
name: 'memory_add',
description: 'Add a new memory entry for future reference. Use when you learn something important about the user, their preferences, or rules they want you to follow.',
parameters: {
type: 'object',
required: ['type', 'content'],
properties: {
type: { type: 'string', enum: ['fact', 'preference', 'rule'], description: 'Memory type: fact (about user), preference (user preference), rule (must follow).' },
content: { type: 'string', description: 'The memory content. Be concise and specific.' },
importance: { type: 'integer', description: 'Importance 1-10. Higher = more important. Default: 5.' },
tags: { type: 'array', items: { type: 'string' }, description: 'Tags for search matching. 2-5 keywords.' }
}
}
}
},
{
type: 'function',
function: {
name: 'session_list',
description: 'List previous chat sessions with their titles and timestamps. Useful for referencing past conversations.',
parameters: {
type: 'object',
properties: {
limit: { type: 'integer', description: 'Max sessions to return. Default: 20.' },
search: { type: 'string', description: 'Filter sessions by title keyword.' }
}
}
}
},
{
type: 'function',
function: {
name: 'session_read',
description: 'Read the messages from a previous chat session. Use when the user references something from a past conversation.',
parameters: {
type: 'object',
required: ['session_id'],
properties: {
session_id: { type: 'string', description: 'The session ID to read.' },
max_messages: { type: 'integer', description: 'Max messages to return. Default: 50.' }
}
}
}
} }
]; ];
@@ -380,7 +445,8 @@ let enabledTools: Set<string> = new Set([
'run_command', 'run_command',
'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file', 'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file',
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
'read_multiple_files', 'git', 'compress' 'read_multiple_files', 'git', 'compress',
'memory_search', 'memory_add', 'session_list', 'session_read'
]); ]);
export function setToolEnabled(toolName: string, enabled: boolean): void { export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -409,6 +475,70 @@ export async function executeTool(toolName: string, args: Record<string, unknown
} }
try { try {
// 内存工具(不走 IPC,直接处理)
if (toolName === 'memory_search') {
const { searchMemories } = await import('./memory-manager.js');
const query = args.query as string;
const limit = (args.limit as number) || 8;
const type = args.type as string | undefined;
let results = searchMemories(query, limit);
if (type) results = results.filter(r => r.type === type);
logToolResult('memory_search', true, `${results.length} 条结果`);
return { success: true, results, total: results.length };
}
if (toolName === 'memory_add') {
const { addMemory } = await import('./memory-manager.js');
const type = args.type as 'fact' | 'preference' | 'rule';
const content = args.content as string;
const importance = (args.importance as number) || 5;
const tags = (args.tags as string[]) || [];
if (!content || content.length < 2) {
return { success: false, error: 'content 不能为空且至少 2 个字符' };
}
const entry = await addMemory({ type, content, importance, tags });
logToolResult('memory_add', true, `${type}: ${content.slice(0, 50)}`);
return { success: true, id: entry.id, type: entry.type, content: entry.content };
}
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const limit = (args.limit as number) || 20;
const search = (args.search as string) || '';
const sessions = await bridge.db.getAllSessions();
let filtered = sessions.map((s: any) => ({
id: s.id,
title: s.title,
model: s.model,
messageCount: 0, // 从 SQLite 获取的原始行不含 messages
createdAt: s.created_at,
updatedAt: s.updated_at
}));
if (search) {
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
}
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
filtered = filtered.slice(0, limit);
logToolResult('session_list', true, `${filtered.length} 个会话`);
return { success: true, sessions: filtered, total: filtered.length };
}
if (toolName === 'session_read') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const sessionId = args.session_id as string;
const maxMessages = (args.max_messages as number) || 50;
if (!sessionId) return { success: false, error: '缺少 session_id 参数' };
const session = await bridge.db.getSession(sessionId);
if (!session) return { success: false, error: `会话 ${sessionId} 不存在` };
const messages = await bridge.db.getMessages(sessionId);
const msgs = messages.slice(0, maxMessages).map((m: any) => ({
role: m.role,
content: m.content || '',
timestamp: m.created_at
}));
logToolResult('session_read', true, `${msgs.length} 条消息`);
return { success: true, session: { id: session.id, title: session.title, model: session.model }, messages: msgs, total: messages.length };
}
// run_command 走 workspace IPC // run_command 走 workspace IPC
if (toolName === 'run_command') { if (toolName === 'run_command') {
const command = args.command as string; const command = args.command as string;
@@ -445,54 +575,27 @@ export async function executeTool(toolName: string, args: Record<string, unknown
export function getToolIcon(name: string): string { export function getToolIcon(name: string): string {
const icons: Record<string, string> = { const icons: Record<string, string> = {
read_file: '📄', read_file: '📄', write_file: '✏️', list_directory: '📁',
write_file: '✏️', search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
list_directory: '📁', move_file: '📦', copy_file: '📋', web_fetch: '🌐', append_file: '',
search_files: '🔍', edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
create_directory: '📂', diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
delete_file: '🗑️', git: '🔖', compress: '🗜️', web_search: '🔍',
run_command: '💻', memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖'
move_file: '📦',
copy_file: '📋',
web_fetch: '🌐',
append_file: '',
edit_file: '✂️',
get_file_info: '️',
tree: '🌳',
download_file: '⬇️',
diff_files: '🔀',
replace_in_files: '🔄',
read_multiple_files: '📚',
git: '🔖',
compress: '🗜️',
web_search: '🔍'
}; };
return icons[name] || '🔧'; return icons[name] || '🔧';
} }
export function formatToolName(name: string): string { export function formatToolName(name: string): string {
const names: Record<string, string> = { const names: Record<string, string> = {
read_file: '读取文件', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
write_file: '写入文件', search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件',
list_directory: '列出目录', run_command: '执行命令', move_file: '移动文件', copy_file: '复制文件',
search_files: '搜索文件', web_fetch: '网页抓取', append_file: '追加文件', edit_file: '编辑文件',
create_directory: '创建目录', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
delete_file: '删除文件', diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
run_command: '执行命令', git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
move_file: '移动文件', memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话'
copy_file: '复制文件',
web_fetch: '网页抓取',
append_file: '追加文件',
edit_file: '编辑文件',
get_file_info: '文件信息',
tree: '目录树',
download_file: '下载文件',
diff_files: '文件对比',
replace_in_files: '批量替换',
read_multiple_files: '批量读取',
git: 'Git 操作',
compress: '压缩/解压',
web_search: '联网搜索'
}; };
return names[name] || name; return names[name] || name;
} }
+124
View File
@@ -2952,3 +2952,127 @@ html, body {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.4; line-height: 1.4;
} }
/* ══════════════════════════════════════════════
v4.0 ReAct 执行面板样式
══════════════════════════════════════════════ */
/* ReAct 思考过程卡片 */
.react-thought-card {
background: rgba(138, 109, 255, 0.06);
border: 1px solid rgba(138, 109, 255, 0.15);
border-radius: 8px;
margin: 8px 0;
overflow: hidden;
transition: border-color 0.2s;
}
.react-thought-card:hover {
border-color: rgba(138, 109, 255, 0.3);
}
.react-thought-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: #a78bfa;
user-select: none;
}
.react-thought-header:hover {
background: rgba(138, 109, 255, 0.08);
}
.react-thought-content {
padding: 0 12px 12px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.react-thought-content pre {
margin: 0;
font-family: inherit;
white-space: pre-wrap;
}
/* ReAct 步骤时间线 */
.react-timeline {
position: relative;
padding-left: 20px;
margin: 12px 0;
}
.react-timeline::before {
content: '';
position: absolute;
left: 6px;
top: 0;
bottom: 0;
width: 2px;
background: rgba(138, 109, 255, 0.2);
border-radius: 1px;
}
.react-step {
position: relative;
margin-bottom: 12px;
}
.react-step::before {
content: '';
position: absolute;
left: -18px;
top: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #a78bfa;
border: 2px solid var(--bg-primary);
}
.react-step-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.react-step-label.thought { color: #a78bfa; }
.react-step-label.action { color: #60cdff; }
.react-step-label.observation { color: #4ade80; }
.react-step-content {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
/* 工具调用卡片增强(v4.0 耗时显示) */
.tool-call-duration {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
/* ReAct 执行摘要 */
.react-summary {
background: rgba(74, 222, 128, 0.06);
border: 1px solid rgba(74, 222, 128, 0.15);
border-radius: 8px;
padding: 10px 14px;
margin: 10px 0;
font-size: 13px;
line-height: 1.5;
}
.react-summary-title {
font-weight: 600;
color: #4ade80;
margin-bottom: 6px;
font-size: 12px;
}
+86
View File
@@ -221,6 +221,7 @@ export interface MetonaDesktopAPI {
onAppQuit: (callback: () => void) => void; onAppQuit: (callback: () => void) => void;
removeAllListeners: (channel: string) => void; removeAllListeners: (channel: string) => void;
onMainLog: (callback: (data: { level: string; message: string; detail?: string }) => void) => void; onMainLog: (callback: (data: { level: string; message: string; detail?: string }) => void) => void;
db: DBAPI;
workspace: { workspace: {
getDir: () => Promise<{ dir: string; defaultDir: string }>; getDir: () => Promise<{ dir: string; defaultDir: string }>;
setDir: (dir: string) => Promise<{ success: boolean; dir?: string; error?: string }>; setDir: (dir: string) => Promise<{ success: boolean; dir?: string; error?: string }>;
@@ -332,6 +333,91 @@ export interface ToolCallRecord {
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done'; export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
// ═══════════════════════════════════════════════════════════
// ReAct Trace 类型 (v4.0)
// ═══════════════════════════════════════════════════════════
export interface TraceEntry {
id: string;
sessionId: string;
stepIndex: number;
thought: string;
action: string;
actionInput: string;
observation: string;
loopCount: number;
createdAt: number;
}
// ═══════════════════════════════════════════════════════════
// SQLite DB IPC 接口 (v4.0)
// ═══════════════════════════════════════════════════════════
export interface SessionRow {
id: string;
title: string;
model: string;
system_prompt: string | null;
parent_id: string | null;
status: string;
created_at: number;
updated_at: number;
}
export interface MessageRow {
id: string;
session_id: string;
role: string;
content: string | null;
thinking: string | null;
images: string | null;
tool_calls: string | null;
tool_name: string | null;
eval_count: number | null;
total_duration: number | null;
created_at: number;
}
export interface MemoryRow {
id: string;
type: string;
content: string;
importance: number;
tags: string | null;
source: string | null;
session_id: string | null;
use_count: number;
embedding: string | null;
created_at: number;
updated_at: number;
last_used_at: number;
}
export interface DBAPI {
saveSession: (session: SessionRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getSession: (id: string) => Promise<SessionRow | null>;
getAllSessions: () => Promise<SessionRow[]>;
deleteSession: (id: string) => Promise<{ success: boolean; error?: string }>;
clearAllSessions: () => Promise<{ success: boolean; error?: string }>;
saveMessage: (msg: MessageRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getMessages: (sessionId: string) => Promise<MessageRow[]>;
saveMemory: (entry: MemoryRow) => Promise<{ success: boolean; id?: string; error?: string }>;
getMemory: (id: string) => Promise<MemoryRow | null>;
getAllMemories: () => Promise<MemoryRow[]>;
getMemoriesByType: (type: string) => Promise<MemoryRow[]>;
deleteMemory: (id: string) => Promise<{ success: boolean; error?: string }>;
clearAllMemories: () => Promise<{ success: boolean; error?: string }>;
searchMemories: (query: string, limit?: number) => Promise<MemoryRow[]>;
saveSetting: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
getSetting: <T = unknown>(key: string, defaultValue?: T) => Promise<T>;
saveToolCall: (tc: unknown) => Promise<{ success: boolean; id?: string; error?: string }>;
getToolCalls: (sessionId: string) => Promise<unknown[]>;
saveTrace: (trace: unknown) => Promise<{ success: boolean; id?: string; error?: string }>;
getTraces: (sessionId: string) => Promise<TraceEntry[]>;
exportSessions: () => Promise<unknown>;
importSessions: (data: unknown) => Promise<{ imported: number; skipped: number }>;
}
// ── Window global 声明 ── // ── Window global 声明 ──
declare global { declare global {