update apply diff

This commit is contained in:
duanfuxiang 2025-03-23 09:34:44 +08:00
parent 570e8d9564
commit 635db9babd
34 changed files with 3161 additions and 410 deletions

View File

@ -27,6 +27,10 @@ const context = await esbuild.context({
'fs',
'obsidian',
'electron',
'path',
'moment',
'node:events',
'child_process',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',

View File

@ -17,6 +17,7 @@
"license": "MIT",
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/diff-match-patch": "^1.0.36",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.14.195",
"@types/lodash.debounce": "^4.0.9",
@ -67,9 +68,11 @@
"axios": "^1.8.3",
"clsx": "^2.1.1",
"diff": "^7.0.0",
"diff-match-patch": "^1.0.5",
"drizzle-orm": "^0.35.2",
"esbuild-plugin-inline-worker": "^0.1.1",
"exponential-backoff": "^3.1.1",
"fastest-levenshtein": "^1.0.16",
"fuse.js": "^7.1.0",
"fuzzysort": "^3.1.0",
"groq-sdk": "^0.7.0",
@ -96,6 +99,8 @@
"react-syntax-highlighter": "^15.5.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"simple-git": "^3.27.0",
"string-similarity": "^4.0.4",
"uuid": "^10.0.0",
"zod": "^3.22.4"
}

202
pnpm-lock.yaml generated
View File

@ -59,6 +59,9 @@ importers:
diff:
specifier: ^7.0.0
version: 7.0.0
diff-match-patch:
specifier: ^1.0.5
version: 1.0.5
drizzle-orm:
specifier: ^0.35.2
version: 0.35.3(@electric-sql/pglite@0.2.14)(@libsql/client-wasm@0.14.0)(@types/react@18.3.18)(react@18.3.1)
@ -68,6 +71,9 @@ importers:
exponential-backoff:
specifier: ^3.1.1
version: 3.1.2
fastest-levenshtein:
specifier: ^1.0.16
version: 1.0.16
fuse.js:
specifier: ^7.1.0
version: 7.1.0
@ -88,7 +94,7 @@ importers:
version: 2.2.3
langchain:
specifier: ^0.3.2
version: 0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0)
version: 0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(cheerio@0.16.0)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0)
lexical:
specifier: ^0.17.1
version: 0.17.1
@ -146,6 +152,12 @@ importers:
remark-gfm:
specifier: ^4.0.0
version: 4.0.1
simple-git:
specifier: ^3.27.0
version: 3.27.0
string-similarity:
specifier: ^4.0.4
version: 4.0.4
uuid:
specifier: ^10.0.0
version: 10.0.0
@ -156,6 +168,9 @@ importers:
'@types/diff':
specifier: ^5.2.3
version: 5.2.3
'@types/diff-match-patch':
specifier: ^1.0.36
version: 1.0.36
'@types/jest':
specifier: ^29.5.13
version: 29.5.14
@ -1170,6 +1185,12 @@ packages:
'@keyv/serialize@1.0.2':
resolution: {integrity: sha512-+E/LyaAeuABniD/RvUezWVXKpeuvwLEA9//nE9952zBaOdBd2mQ3pPoM8cUe2X6IcMByfuSLzmYqnYshG60+HQ==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@langchain/core@0.3.40':
resolution: {integrity: sha512-RGhJOTzJv6H+3veBAnDlH2KXuZ68CXMEg6B6DPTzL3IGDyd+vLxXG4FIttzUwjdeQKjrrFBwlXpJDl7bkoApzQ==}
engines: {node: '>=18'}
@ -1677,6 +1698,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
'@types/diff@5.2.3':
resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
@ -1851,6 +1875,14 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
CSSselect@0.4.1:
resolution: {integrity: sha512-r4HWARRbQ6enGbdPCrl3bNybORIcU0AcBLTyaxcWNTRd6EH2/w9RInHkUbUhwehrBFN1KQz+yFulhyIH31ZXAw==}
deprecated: the module is now available as 'css-select'
CSSwhat@0.4.7:
resolution: {integrity: sha512-bU5cYG02crjQGDN6wm8USThp/sr/MUulMTrVA1CENSBhv3B+mlJfYDP1em/wJlMT0aYcWso0cuT9NXW74yPfog==}
deprecated: the module is now available as 'css-what'
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@ -2108,6 +2140,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
cheerio@0.16.0:
resolution: {integrity: sha512-GB+YYcb/2s1HpNXThiHyl1PO5evlkX+avFzggq9/4JZGLGbtMT5FE9GUFjxH+5nObb4Lfu72hAH4lqGljog0Mw==}
engines: {node: '>= 0.6'}
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
@ -2162,6 +2198,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
@ -2290,6 +2329,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2310,11 +2352,29 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
domelementtype@1.3.1:
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
domhandler@2.2.1:
resolution: {integrity: sha512-MFFBQFGkyTuNe3vL9WEw9JdlCwIoBYpOGESLeZAvc/jClYNsOl6P1KzevJbWg76GovdEycfR7/2/Ra7NnqtMKw==}
domutils@1.4.3:
resolution: {integrity: sha512-ZkVgS/PpxjyJMb+S2iVHHEZjVnOUtjGp0/zstqKGTE9lrZtNHlNQmLwP/lhLMEApYbzc08BKMx9IFpKhaSbW1w==}
domutils@1.5.1:
resolution: {integrity: sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==}
drizzle-kit@0.26.2:
resolution: {integrity: sha512-cMq8omEKywjIy5KcqUo6LvEFxkl8/zYHsgYjFVXjmPWWtuW4blcz+YW9+oIhoaALgs2ebRjzXwsJgN9i6P49Dw==}
hasBin: true
@ -2428,6 +2488,15 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
entities@1.0.0:
resolution: {integrity: sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==}
entities@1.1.2:
resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -2960,6 +3029,9 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@3.7.3:
resolution: {integrity: sha512-XdyuCBH3/tTuRTCMFolbj5stKZek8FK7KVXm+aHYivHmXVo18jINvc2jR5zgFkp//z2KWl5vppTJ4DWhltYruA==}
http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@ -3173,6 +3245,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
isarray@0.0.1:
resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -3546,6 +3621,10 @@ packages:
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash@2.4.2:
resolution: {integrity: sha512-Kak1hi6/hYHGVPmdyiZijoQyz5x2iGVzs6w9GYB/HiXEtylY7tIoYEROMjvM1d9nXJqPOrG2MNPMn01bJ+S0Rw==}
engines: {'0': node, '1': rhino}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -4133,6 +4212,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
readable-stream@1.1.14:
resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -4291,6 +4373,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git@3.27.0:
resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==}
simple-wcswidth@1.0.1:
resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==}
@ -4336,6 +4421,10 @@ packages:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
string-similarity@4.0.4:
resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@ -4359,6 +4448,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@0.10.31:
resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -5549,6 +5641,14 @@ snapshots:
dependencies:
buffer: 6.0.3
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.0
transitivePeerDependencies:
- supports-color
'@kwsites/promise-deferred@1.1.1': {}
'@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2))':
dependencies:
'@cfworker/json-schema': 4.1.1
@ -6186,6 +6286,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/diff-match-patch@1.0.36': {}
'@types/diff@5.2.3': {}
'@types/eslint-utils@3.0.5':
@ -6395,6 +6497,15 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
CSSselect@0.4.1:
dependencies:
CSSwhat: 0.4.7
domutils: 1.4.3
optional: true
CSSwhat@0.4.7:
optional: true
abab@2.0.6: {}
abort-controller@3.0.0:
@ -6706,6 +6817,14 @@ snapshots:
character-reference-invalid@2.0.1: {}
cheerio@0.16.0:
dependencies:
CSSselect: 0.4.1
entities: 1.1.2
htmlparser2: 3.7.3
lodash: 2.4.2
optional: true
ci-info@3.9.0: {}
cjs-module-lexer@1.4.3: {}
@ -6748,6 +6867,9 @@ snapshots:
convert-source-map@2.0.0: {}
core-util-is@1.0.3:
optional: true
cosmiconfig@9.0.0(typescript@4.9.5):
dependencies:
env-paths: 2.2.1
@ -6867,6 +6989,8 @@ snapshots:
dependencies:
dequal: 2.0.3
diff-match-patch@1.0.5: {}
diff-sequences@29.6.3: {}
diff@7.0.0: {}
@ -6883,10 +7007,38 @@ snapshots:
dependencies:
esutils: 2.0.3
dom-serializer@0.2.2:
dependencies:
domelementtype: 2.3.0
entities: 2.2.0
optional: true
domelementtype@1.3.1:
optional: true
domelementtype@2.3.0:
optional: true
domexception@4.0.0:
dependencies:
webidl-conversions: 7.0.0
domhandler@2.2.1:
dependencies:
domelementtype: 1.3.1
optional: true
domutils@1.4.3:
dependencies:
domelementtype: 1.3.1
optional: true
domutils@1.5.1:
dependencies:
dom-serializer: 0.2.2
domelementtype: 1.3.1
optional: true
drizzle-kit@0.26.2:
dependencies:
'@drizzle-team/brocli': 0.10.2
@ -6920,6 +7072,15 @@ snapshots:
emoji-regex@8.0.0: {}
entities@1.0.0:
optional: true
entities@1.1.2:
optional: true
entities@2.2.0:
optional: true
entities@4.5.0: {}
env-paths@2.2.1: {}
@ -7725,6 +7886,15 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@3.7.3:
dependencies:
domelementtype: 1.3.1
domhandler: 2.2.1
domutils: 1.5.1
entities: 1.0.0
readable-stream: 1.1.14
optional: true
http-proxy-agent@5.0.0:
dependencies:
'@tootallnate/once': 2.0.0
@ -7930,6 +8100,9 @@ snapshots:
call-bound: 1.0.3
get-intrinsic: 1.2.7
isarray@0.0.1:
optional: true
isarray@2.0.5: {}
isexe@2.0.0: {}
@ -8407,7 +8580,7 @@ snapshots:
known-css-properties@0.35.0: {}
langchain@0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0):
langchain@0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(cheerio@0.16.0)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0):
dependencies:
'@langchain/core': 0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2))
'@langchain/openai': 0.4.4(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(ws@8.18.0)
@ -8424,6 +8597,7 @@ snapshots:
zod-to-json-schema: 3.24.1(zod@3.24.2)
optionalDependencies:
axios: 1.8.3
cheerio: 0.16.0
handlebars: 4.7.8
transitivePeerDependencies:
- encoding
@ -8479,6 +8653,9 @@ snapshots:
lodash.truncate@4.4.2: {}
lodash@2.4.2:
optional: true
lodash@4.17.21: {}
longest-streak@3.1.0: {}
@ -9265,6 +9442,14 @@ snapshots:
dependencies:
loose-envify: 1.4.0
readable-stream@1.1.14:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 0.0.1
string_decoder: 0.10.31
optional: true
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -9468,6 +9653,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-git@3.27.0:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.0
transitivePeerDependencies:
- supports-color
simple-wcswidth@1.0.1: {}
sisteransi@1.0.5: {}
@ -9509,6 +9702,8 @@ snapshots:
char-regex: 1.0.2
strip-ansi: 6.0.1
string-similarity@4.0.4: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@ -9559,6 +9754,9 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@0.10.31:
optional: true
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0

View File

@ -9,6 +9,7 @@ import { AppProvider } from './contexts/AppContext'
import { DarkModeProvider } from './contexts/DarkModeContext'
import { DatabaseProvider } from './contexts/DatabaseContext'
import { DialogProvider } from './contexts/DialogContext'
import { DiffStrategyProvider } from './contexts/DiffStrategyContext'
import { LLMProvider } from './contexts/LLMContext'
import { RAGProvider } from './contexts/RAGContext'
import { SettingsProvider } from './contexts/SettingsContext'
@ -17,101 +18,103 @@ import { MentionableBlockData } from './types/mentionable'
import { InfioSettings } from './types/settings'
export class ChatView extends ItemView {
private root: Root | null = null
private settings: InfioSettings
private initialChatProps?: ChatProps
private chatRef: React.RefObject<ChatRef> = React.createRef()
private root: Root | null = null
private settings: InfioSettings
private initialChatProps?: ChatProps
private chatRef: React.RefObject<ChatRef> = React.createRef()
constructor(
leaf: WorkspaceLeaf,
private plugin: InfioPlugin,
) {
super(leaf)
this.settings = plugin.settings
this.initialChatProps = plugin.initChatProps
}
constructor(
leaf: WorkspaceLeaf,
private plugin: InfioPlugin,
) {
super(leaf)
this.settings = plugin.settings
this.initialChatProps = plugin.initChatProps
}
getViewType() {
return CHAT_VIEW_TYPE
}
getViewType() {
return CHAT_VIEW_TYPE
}
getIcon() {
return 'wand-sparkles'
}
getIcon() {
return 'wand-sparkles'
}
getDisplayText() {
return 'Infio chat'
}
getDisplayText() {
return 'Infio chat'
}
async onOpen() {
await this.render()
async onOpen() {
await this.render()
// Consume chatProps
this.initialChatProps = undefined
}
// Consume chatProps
this.initialChatProps = undefined
}
async onClose() {
this.root?.unmount()
}
async onClose() {
this.root?.unmount()
}
async render() {
if (!this.root) {
this.root = createRoot(this.containerEl.children[1])
}
async render() {
if (!this.root) {
this.root = createRoot(this.containerEl.children[1])
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 0, // Immediately garbage collect queries. It prevents memory leak on ChatView close.
},
mutations: {
gcTime: 0, // Immediately garbage collect mutations. It prevents memory leak on ChatView close.
},
},
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 0, // Immediately garbage collect queries. It prevents memory leak on ChatView close.
},
mutations: {
gcTime: 0, // Immediately garbage collect mutations. It prevents memory leak on ChatView close.
},
},
})
this.root.render(
<AppProvider app={this.app}>
<SettingsProvider
settings={this.settings}
setSettings={(newSettings) => this.plugin.setSettings(newSettings)}
addSettingsChangeListener={(listener) =>
this.plugin.addSettingsListener(listener)
}
>
<DarkModeProvider>
<LLMProvider>
<DatabaseProvider
getDatabaseManager={() => this.plugin.getDbManager()}
>
<RAGProvider getRAGEngine={() => this.plugin.getRAGEngine()}>
<QueryClientProvider client={queryClient}>
<React.StrictMode>
<DialogProvider
container={this.containerEl.children[1] as HTMLElement}
>
<Chat ref={this.chatRef} {...this.initialChatProps} />
</DialogProvider>
</React.StrictMode>
</QueryClientProvider>
</RAGProvider>
</DatabaseProvider>
</LLMProvider>
</DarkModeProvider>
</SettingsProvider>
</AppProvider>,
)
}
this.root.render(
<AppProvider app={this.app}>
<SettingsProvider
settings={this.settings}
setSettings={(newSettings) => this.plugin.setSettings(newSettings)}
addSettingsChangeListener={(listener) =>
this.plugin.addSettingsListener(listener)
}
>
<DarkModeProvider>
<LLMProvider>
<DatabaseProvider
getDatabaseManager={() => this.plugin.getDbManager()}
>
<DiffStrategyProvider diffStrategy={this.plugin.diffStrategy}>
<RAGProvider getRAGEngine={() => this.plugin.getRAGEngine()}>
<QueryClientProvider client={queryClient}>
<React.StrictMode>
<DialogProvider
container={this.containerEl.children[1] as HTMLElement}
>
<Chat ref={this.chatRef} {...this.initialChatProps} />
</DialogProvider>
</React.StrictMode>
</QueryClientProvider>
</RAGProvider>
</DiffStrategyProvider>
</DatabaseProvider>
</LLMProvider>
</DarkModeProvider>
</SettingsProvider>
</AppProvider>,
)
}
openNewChat(selectedBlock?: MentionableBlockData) {
this.chatRef.current?.openNewChat(selectedBlock)
}
openNewChat(selectedBlock?: MentionableBlockData) {
this.chatRef.current?.openNewChat(selectedBlock)
}
addSelectionToChat(selectedBlock: MentionableBlockData) {
this.chatRef.current?.addSelectionToChat(selectedBlock)
}
addSelectionToChat(selectedBlock: MentionableBlockData) {
this.chatRef.current?.addSelectionToChat(selectedBlock)
}
focusMessage() {
this.chatRef.current?.focusMessage()
}
focusMessage() {
this.chatRef.current?.focusMessage()
}
}

View File

@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid'
import { ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext'
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
import { useLLM } from '../../contexts/LLMContext'
import { useRAG } from '../../contexts/RAGContext'
import { useSettings } from '../../contexts/SettingsContext'
@ -93,6 +94,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp()
const { settings, setSettings } = useSettings()
const { getRAGEngine } = useRAG()
const diffStrategy = useDiffStrategy()
const {
createOrUpdateConversation,
@ -104,8 +106,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const { streamResponse, chatModel } = useLLM()
const promptGenerator = useMemo(() => {
return new PromptGenerator(getRAGEngine, app, settings)
}, [getRAGEngine, app, settings])
return new PromptGenerator(getRAGEngine, app, settings, diffStrategy)
}, [getRAGEngine, app, settings, diffStrategy])
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
const newMessage = getNewInputMessage(app)
@ -382,7 +384,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
if (!applyRes) {
throw new Error('Failed to apply edit changes')
}
// 返回一个Promise该Promise会在用户做出选择后解析
// return a Promise, which will be resolved after user makes a choice
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
@ -419,7 +421,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
fileContent,
toolArgs.operations
)
// 返回一个Promise该Promise会在用户做出选择后解析
if (!applyRes) {
throw new Error('Failed to search_and_replace')
}
// return a Promise, which will be resolved after user makes a choice
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
@ -449,6 +454,45 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
} satisfies ApplyViewState,
})
})
} else if (toolArgs.type === 'apply_diff') {
const diffResult = await diffStrategy.applyDiff(
activeFileContent,
toolArgs.diff
)
if (!diffResult.success) {
console.log(diffResult)
throw new Error(`Failed to apply_diff`)
}
// return a Promise, which will be resolved after user makes a choice
return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: activeFileContent,
newContent: diffResult.content,
onClose: (applied: boolean) => {
const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected
const applyEditContent = applied ? 'Changes successfully applied'
: 'User rejected changes'
resolve({
type: 'apply_diff',
applyMsgId,
applyStatus,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: `[apply_diff for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`,
id: uuidv4(),
mentionables: [],
}
});
}
} satisfies ApplyViewState,
})
})
} else if (toolArgs.type === 'read_file') {
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`;

View File

@ -0,0 +1,93 @@
import { Check, Edit, Loader2, X } from 'lucide-react'
import { PropsWithChildren, useState } from 'react'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
export default function MarkdownApplyDiffBlock({
mode,
applyStatus,
onApply,
path,
diff,
finish,
}: PropsWithChildren<{
mode: string
applyStatus: ApplyStatus
onApply: (args: ToolArgs) => void
path: string
diff: string
finish: boolean
}>) {
const [applying, setApplying] = useState(false)
const { isDarkMode } = useDarkModeContext()
console.log('MarkdownApplyDiffBlock', { mode, applyStatus, onApply, path, diff, finish })
const handleApply = async () => {
if (applyStatus !== ApplyStatus.Idle) {
return
}
setApplying(true)
onApply({
type: "apply_diff",
filepath: path,
diff,
finish,
})
}
return (
<div className={`infio-chat-code-block ${path ? 'has-filename' : ''} infio-reasoning-block`}>
<div className={'infio-chat-code-block-header'}>
{path && (
<div className={'infio-chat-code-block-header-filename'}>
<Edit size={10} className="infio-chat-code-block-header-icon" />
{mode}: {path}
</div>
)}
<div className={'infio-chat-code-block-header-button'}>
<button
onClick={handleApply}
style={{ color: '#008000' }}
disabled={applyStatus !== ApplyStatus.Idle || applying || !finish}
>
{
!finish ? (
<>
<Loader2 className="spinner" size={14} /> Loading...
</>
) : applyStatus === ApplyStatus.Idle ? (
applying ? (
<>
<Loader2 className="spinner" size={14} /> Applying...
</>
) : (
'Apply'
)
) : applyStatus === ApplyStatus.Applied ? (
<>
<Check size={14} /> Success
</>
) : (
<>
<X size={14} /> Failed
</>
)}
</button>
</div>
</div>
<div className="infio-reasoning-content-wrapper">
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="diff"
hasFilename={!!path}
wrapLines={true}
>
{diff}
</MemoizedSyntaxHighlighterWrapper>
</div>
</div>
)
}

View File

@ -1,24 +1,29 @@
import { Check, Loader2, Replace, X } from 'lucide-react'
import React from 'react'
import React, { useMemo } from 'react'
import { useApp } from '../../contexts/AppContext'
import { ApplyStatus, SearchAndReplaceToolArgs } from '../../types/apply'
import { openMarkdownFile } from '../../utils/obsidian'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
export default function MarkdownSearchAndReplace({
applyStatus,
onApply,
path,
content,
operations,
finish
}: {
applyStatus: ApplyStatus
onApply: (args: SearchAndReplaceToolArgs) => void
path: string,
content: string,
operations: SearchAndReplaceToolArgs['operations'],
finish: boolean
}) {
const app = useApp()
const { isDarkMode } = useDarkModeContext()
const [applying, setApplying] = React.useState(false)
@ -51,9 +56,13 @@ export default function MarkdownSearchAndReplace({
<div className={'infio-chat-code-block-header-button'}>
<button
onClick={handleApply}
disabled={applyStatus !== ApplyStatus.Idle || applying}
disabled={applyStatus !== ApplyStatus.Idle || applying || !finish}
>
{applyStatus === ApplyStatus.Idle ? (
{!finish ? (
<>
<Loader2 className="spinner" size={14} />
</>
) : applyStatus === ApplyStatus.Idle ? (
applying ? (
<>
<Loader2 className="spinner" size={14} /> Applying...
@ -73,6 +82,14 @@ export default function MarkdownSearchAndReplace({
</button>
</div>
</div>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="markdown"
hasFilename={!!path}
wrapLines={true}
>
{content}
</MemoizedSyntaxHighlighterWrapper>
</div>
)
}

View File

@ -7,6 +7,7 @@ import {
parseMsgBlocks,
} from '../../utils/parse-infio-block'
import MarkdownApplyDiffBlock from './MarkdownApplyDiffBlock'
import MarkdownEditFileBlock from './MarkdownEditFileBlock'
import MarkdownFetchUrlsContentBlock from './MarkdownFetchUrlsContentBlock'
import MarkdownListFilesBlock from './MarkdownListFilesBlock'
@ -18,6 +19,7 @@ import MarkdownSearchWebBlock from './MarkdownSearchWebBlock'
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock'
import MarkdownWithIcons from './MarkdownWithIcon'
function ReactMarkdown({
applyStatus,
onApply,
@ -27,6 +29,7 @@ function ReactMarkdown({
onApply: (toolArgs: ToolArgs) => void
children: string
}) {
const blocks: ParsedMsgBlock[] = useMemo(
() => parseMsgBlocks(children),
[children],
@ -73,6 +76,7 @@ function ReactMarkdown({
applyStatus={applyStatus}
onApply={onApply}
path={block.path}
content={block.content}
operations={block.operations.map(op => ({
search: op.search,
replace: op.replace,
@ -84,6 +88,16 @@ function ReactMarkdown({
}))}
finish={block.finish}
/>
) : block.type === 'apply_diff' ? (
<MarkdownApplyDiffBlock
key={"apply-diff-" + index}
applyStatus={applyStatus}
mode={block.type}
onApply={onApply}
path={block.path}
diff={block.diff}
finish={block.finish}
/>
) : block.type === 'read_file' ? (
<MarkdownReadFileBlock
key={"read-file-" + index}

View File

@ -0,0 +1,31 @@
import {
PropsWithChildren,
createContext,
useContext,
useMemo
} from 'react'
import { DiffStrategy } from '../core/diff/DiffStrategy'
const DiffStrategyContext = createContext<DiffStrategy>(null)
export function DiffStrategyProvider({
diffStrategy,
children,
}: PropsWithChildren<{ diffStrategy: DiffStrategy }>) {
const value = useMemo(() => {
return diffStrategy
}, [diffStrategy])
return <DiffStrategyContext.Provider value={value}>{children}</DiffStrategyContext.Provider>
}
export function useDiffStrategy() {
const context = useContext(DiffStrategyContext)
if (!context) {
throw new Error('DiffStrategyContext is not initialized')
}
return context
}

View File

@ -34,7 +34,7 @@ export type LLMContextType = {
options?: LLMOptions,
) => Promise<AsyncIterable<LLMResponseStreaming>>
chatModel: LLMModel
applyModel: LLMModel
// applyModel: LLMModel
}
const LLMContext = createContext<LLMContextType | null>(null)
@ -50,12 +50,12 @@ export function LLMProvider({ children }: PropsWithChildren) {
}
}, [settings])
const applyModel = useMemo((): LLMModel => {
return {
provider: settings.applyModelProvider,
modelId: settings.applyModelId,
}
}, [settings])
// const applyModel = useMemo((): LLMModel => {
// return {
// provider: settings.applyModelProvider,
// modelId: settings.applyModelId,
// }
// }, [settings])
useEffect(() => {
const manager = new LLMManager(settings)
@ -92,7 +92,7 @@ export function LLMProvider({ children }: PropsWithChildren) {
return (
<LLMContext.Provider
value={{ generateResponse, streamResponse, chatModel, applyModel }}
value={{ generateResponse, streamResponse, chatModel }}
>
{children}
</LLMContext.Provider>

View File

@ -1,7 +1,10 @@
import type { DiffStrategy } from "./types"
import { UnifiedDiffStrategy } from "./strategies/unified"
import { SearchReplaceDiffStrategy } from "./strategies/search-replace"
import { App } from "obsidian"
import { MultiSearchReplaceDiffStrategy } from "./strategies/multi-search-replace"
import { NewUnifiedDiffStrategy } from "./strategies/new-unified"
import { SearchReplaceDiffStrategy } from "./strategies/search-replace"
import { UnifiedDiffStrategy } from "./strategies/unified"
import type { DiffStrategy } from "./types"
/**
* Get the appropriate diff strategy for the given model
* @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
@ -9,14 +12,22 @@ import { NewUnifiedDiffStrategy } from "./strategies/new-unified"
*/
export function getDiffStrategy(
model: string,
app: App,
fuzzyMatchThreshold?: number,
experimentalDiffStrategy: boolean = false,
multiSearchReplaceDiffStrategy: boolean = false,
): DiffStrategy {
if (experimentalDiffStrategy) {
return new NewUnifiedDiffStrategy(fuzzyMatchThreshold)
}
return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
// if (experimentalDiffStrategy) {
// return new NewUnifiedDiffStrategy(app, fuzzyMatchThreshold)
// }
// if (multiSearchReplaceDiffStrategy) {
// return new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold)
// } else {
// return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
// }
return new MultiSearchReplaceDiffStrategy(0.9)
}
export { SearchReplaceDiffStrategy, UnifiedDiffStrategy }
export type { DiffStrategy }
export { UnifiedDiffStrategy, SearchReplaceDiffStrategy }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,395 @@
import { distance } from "fastest-levenshtein"
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text"
// import { ToolProgressStatus } from "../../../shared/ExtensionMessage"
// import { ToolUse } from "../../assistant-message"
import { DiffResult, DiffStrategy } from "../types"
const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
function getSimilarity(original: string, search: string): number {
if (search === "") {
return 1
}
// Normalize strings by removing extra whitespace but preserve case
const normalizeStr = (str: string) => str.replace(/\s+/g, " ").trim()
const normalizedOriginal = normalizeStr(original)
const normalizedSearch = normalizeStr(search)
if (normalizedOriginal === normalizedSearch) {
return 1
}
// Calculate Levenshtein distance using fastest-levenshtein's distance function
const dist = distance(normalizedOriginal, normalizedSearch)
// Calculate similarity ratio (0 to 1, where 1 is an exact match)
const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length)
return 1 - dist / maxLength
}
export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
private fuzzyThreshold: number
private bufferLines: number
getName(): string {
return "MultiSearchReplace"
}
constructor(fuzzyThreshold?: number, bufferLines?: number) {
// Use provided threshold or default to exact matching (1.0)
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
// so we use it directly here
this.fuzzyThreshold = fuzzyThreshold ?? 1.0
this.bufferLines = bufferLines ?? BUFFER_LINES
}
getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
return `## apply_diff
Description: Request to replace existing content in Markdown documents using a search and replace block.
This tool allows for precise modifications to Markdown files by specifying exactly what content to search for and what to replace it with.
The tool will maintain proper formatting while making changes to your Markdown documents.
Only a single operation is allowed per tool use.
The SEARCH section must exactly match existing content including whitespace and indentation.
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
When applying changes to Markdown, be careful about maintaining list structures, heading levels, and other Markdown formatting.
ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
Parameters:
- path: (required) The path of the file to modify (relative to the current working directory ${args.cwd})
- diff: (required) The search/replace block defining the changes.
Diff format:
\`\`\`
<<<<<<< SEARCH
:start_line: (required) The line number of original content where the search block starts.
:end_line: (required) The line number of original content where the search block ends.
-------
[exact content to find including whitespace]
=======
[new content to replace with]
>>>>>>> REPLACE
\`\`\`
Example:
Original Markdown file:
\`\`\`
1 | # Project Notes
2 |
3 | ## Tasks
4 | - [ ] Review documentation
5 | - [ ] Update examples
6 | - [ ] Add new section
\`\`\`
Search/Replace content:
\`\`\`
<<<<<<< SEARCH
:start_line:3
:end_line:6
-------
## Tasks
- [ ] Review documentation
- [ ] Update examples
- [ ] Add new section
=======
## Current Tasks
- [ ] Review documentation
- [x] Update examples
- [ ] Add new section
- [ ] Schedule team meeting
>>>>>>> REPLACE
\`\`\`
Search/Replace content with multi edits:
\`\`\`
<<<<<<< SEARCH
:start_line:1
:end_line:1
-------
# Project Notes
=======
# Project Notes (Updated)
>>>>>>> REPLACE
<<<<<<< SEARCH
:start_line:4
:end_line:5
-------
- [ ] Review documentation
- [ ] Update examples
=======
- [ ] Review documentation (priority)
- [x] Update examples
>>>>>>> REPLACE
\`\`\`
Usage:
<apply_diff>
<path>File path here</path>
<diff>
Your search/replace content here
You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
</diff>
</apply_diff>`
}
async applyDiff(
originalContent: string,
diffContent: string,
_paramStartLine?: number,
_paramEndLine?: number,
): Promise<DiffResult> {
let matches = [
...diffContent.matchAll(
/<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g,
),
]
if (matches.length === 0) {
return {
success: false,
error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n:end_line: end line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/end_line/SEARCH/REPLACE sections with correct markers`,
}
}
// Detect line ending from original content
const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n"
let resultLines = originalContent.split(/\r?\n/)
let delta = 0
let diffResults: DiffResult[] = []
let appliedCount = 0
const replacements = matches
.map((match) => ({
startLine: Number(match[2] ?? 0),
endLine: Number(match[4] ?? resultLines.length),
searchContent: match[6],
replaceContent: match[7],
}))
.sort((a, b) => a.startLine - b.startLine)
for (let { searchContent, replaceContent, startLine, endLine } of replacements) {
startLine += startLine === 0 ? 0 : delta
endLine += delta
// Strip line numbers from search and replace content if every line starts with a line number
if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) {
searchContent = stripLineNumbers(searchContent)
replaceContent = stripLineNumbers(replaceContent)
}
// Split content into lines, handling both \n and \r\n
const searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/)
const replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/)
// Validate that empty search requires start line
if (searchLines.length === 0 && !startLine) {
diffResults.push({
success: false,
error: `Empty search content requires start_line to be specified\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, specify the line number where content should be inserted`,
})
continue
}
// Validate that empty search requires same start and end line
if (searchLines.length === 0 && startLine && endLine && startLine !== endLine) {
diffResults.push({
success: false,
error: `Empty search content requires start_line and end_line to be the same (got ${startLine}-${endLine})\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, use the same line number for both start_line and end_line`,
})
continue
}
// Initialize search variables
let matchIndex = -1
let bestMatchScore = 0
let bestMatchContent = ""
const searchChunk = searchLines.join("\n")
// Determine search bounds
let searchStartIndex = 0
let searchEndIndex = resultLines.length
// Validate and handle line range if provided
if (startLine && endLine) {
// Convert to 0-based index
const exactStartIndex = startLine - 1
const exactEndIndex = endLine - 1
if (exactStartIndex < 0 || exactEndIndex > resultLines.length || exactStartIndex > exactEndIndex) {
diffResults.push({
success: false,
error: `Line range ${startLine}-${endLine} is invalid (file has ${resultLines.length} lines)\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${resultLines.length}`,
})
continue
}
// Try exact match first
const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n")
const similarity = getSimilarity(originalChunk, searchChunk)
if (similarity >= this.fuzzyThreshold) {
matchIndex = exactStartIndex
bestMatchScore = similarity
bestMatchContent = originalChunk
} else {
// Set bounds for buffered search
searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1))
searchEndIndex = Math.min(resultLines.length, endLine + this.bufferLines)
}
}
// If no match found yet, try middle-out search within bounds
if (matchIndex === -1) {
const midPoint = Math.floor((searchStartIndex + searchEndIndex) / 2)
let leftIndex = midPoint
let rightIndex = midPoint + 1
// Search outward from the middle within bounds
while (leftIndex >= searchStartIndex || rightIndex <= searchEndIndex - searchLines.length) {
// Check left side if still in range
if (leftIndex >= searchStartIndex) {
const originalChunk = resultLines.slice(leftIndex, leftIndex + searchLines.length).join("\n")
const similarity = getSimilarity(originalChunk, searchChunk)
if (similarity > bestMatchScore) {
bestMatchScore = similarity
matchIndex = leftIndex
bestMatchContent = originalChunk
}
leftIndex--
}
// Check right side if still in range
if (rightIndex <= searchEndIndex - searchLines.length) {
const originalChunk = resultLines.slice(rightIndex, rightIndex + searchLines.length).join("\n")
const similarity = getSimilarity(originalChunk, searchChunk)
if (similarity > bestMatchScore) {
bestMatchScore = similarity
matchIndex = rightIndex
bestMatchContent = originalChunk
}
rightIndex++
}
}
}
// Require similarity to meet threshold
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
const searchChunk = searchLines.join("\n")
const originalContentSection =
startLine !== undefined && endLine !== undefined
? `\n\nOriginal Content:\n${addLineNumbers(
resultLines
.slice(
Math.max(0, startLine - 1 - this.bufferLines),
Math.min(resultLines.length, endLine + this.bufferLines),
)
.join("\n"),
Math.max(1, startLine - this.bufferLines),
)}`
: `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}`
const bestMatchSection = bestMatchContent
? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
: `\n\nBest Match Found:\n(no match)`
const lineRange =
startLine || endLine
? ` at ${startLine ? `start: ${startLine}` : "start"} to ${endLine ? `end: ${endLine}` : "end"}`
: ""
diffResults.push({
success: false,
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n- Tip: Use read_file to get the latest content of the file before attempting the diff again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
})
continue
}
// Get the matched lines from the original content
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
// Get the exact indentation (preserving tabs/spaces) of each line
const originalIndents = matchedLines.map((line) => {
const match = line.match(/^[\t ]*/)
return match ? match[0] : ""
})
// Get the exact indentation of each line in the search block
const searchIndents = searchLines.map((line) => {
const match = line.match(/^[\t ]*/)
return match ? match[0] : ""
})
// Apply the replacement while preserving exact indentation
const indentedReplaceLines = replaceLines.map((line, i) => {
// Get the matched line's exact indentation
const matchedIndent = originalIndents[0] || ""
// Get the current line's indentation relative to the search content
const currentIndentMatch = line.match(/^[\t ]*/)
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
const searchBaseIndent = searchIndents[0] || ""
// Calculate the relative indentation level
const searchBaseLevel = searchBaseIndent.length
const currentLevel = currentIndent.length
const relativeLevel = currentLevel - searchBaseLevel
// If relative level is negative, remove indentation from matched indent
// If positive, add to matched indent
const finalIndent =
relativeLevel < 0
? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
: matchedIndent + currentIndent.slice(searchBaseLevel)
return finalIndent + line.trim()
})
// Construct the final content
const beforeMatch = resultLines.slice(0, matchIndex)
const afterMatch = resultLines.slice(matchIndex + searchLines.length)
resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch]
delta = delta - matchedLines.length + replaceLines.length
appliedCount++
}
const finalContent = resultLines.join(lineEnding)
if (appliedCount === 0) {
return {
success: false,
failParts: diffResults,
}
}
return {
success: true,
content: finalContent,
failParts: diffResults,
}
}
getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
const diffContent = toolUse.params.diff
if (diffContent) {
const icon = "diff-multiple"
const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
if (toolUse.partial) {
if (diffContent.length < 1000 || (diffContent.length / 50) % 10 === 0) {
return { icon, text: `${searchBlockCount}` }
}
} else if (result) {
if (result.failParts?.length) {
return {
icon,
text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`,
}
} else {
return { icon, text: `${searchBlockCount}` }
}
}
}
return {}
}
}

View File

@ -1,31 +1,35 @@
import { diff_match_patch } from "diff-match-patch"
import { EditResult, Hunk } from "./types"
import { getDMPSimilarity, validateEditResult } from "./search-strategies"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as tmp from "tmp"
import { App, FileSystemAdapter, normalizePath } from "obsidian"
import * as fs from "fs"
import * as path from "path"
// Helper function to infer indentation - simplified version
function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string {
// If the line has explicit indentation in the change, use it exactly
const lineMatch = line.match(/^(\s+)/)
if (lineMatch) {
return lineMatch[1]
}
import { diff_match_patch } from "diff-match-patch"
import simpleGit, { SimpleGit } from "simple-git"
// If we have context lines, use the indentation from the first context line
const contextLine = contextLines[0]
if (contextLine) {
const contextMatch = contextLine.match(/^(\s+)/)
if (contextMatch) {
return contextMatch[1]
}
}
import { validateEditResult } from "./search-strategies"
import { EditResult, Hunk } from "./types"
// Fallback to previous indent
return previousIndent
}
// // Helper function to infer indentation - simplified version
// function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string {
// // If the line has explicit indentation in the change, use it exactly
// const lineMatch = line.match(/^(\s+)/)
// if (lineMatch) {
// return lineMatch[1]
// }
// // If we have context lines, use the indentation from the first context line
// const contextLine = contextLines[0]
// if (contextLine) {
// const contextMatch = contextLine.match(/^(\s+)/)
// if (contextMatch) {
// return contextMatch[1]
// }
// }
// // Fallback to previous indent
// return previousIndent
// }
// Context matching edit strategy
export function applyContextMatching(hunk: Hunk, content: string[], matchPosition: number): EditResult {
@ -147,18 +151,28 @@ export function applyDMP(hunk: Hunk, content: string[], matchPosition: number):
}
// Git fallback strategy that works with full content
export async function applyGitFallback(hunk: Hunk, content: string[]): Promise<EditResult> {
let tmpDir: tmp.DirResult | undefined
export async function applyGitFallback(app: App, hunk: Hunk, content: string[]): Promise<EditResult> {
// let tmpDir: tmp.DirResult | undefined
const adapter = app.vault.adapter as FileSystemAdapter;
const vaultBasePath = adapter.getBasePath();
const tmpGitPath = normalizePath(path.join(vaultBasePath, ".tmp_git"));
console.log("tmpGitPath", tmpGitPath)
try {
tmpDir = tmp.dirSync({ unsafeCleanup: true })
const git: SimpleGit = simpleGit(tmpDir.name)
const exists = await adapter.exists(tmpGitPath);
if (exists) {
await adapter.rmdir(tmpGitPath, true);
}
await adapter.mkdir(tmpGitPath);
// tmpDir = tmp.dirSync({ unsafeCleanup: true })
const git: SimpleGit = simpleGit(tmpGitPath)
await git.init()
await git.addConfig("user.name", "Temp")
await git.addConfig("user.email", "temp@example.com")
const filePath = path.join(tmpDir.name, "file.txt")
const filePath = path.join(tmpGitPath, "file.txt")
const searchLines = hunk.changes
.filter((change) => change.type === "context" || change.type === "remove")
@ -256,14 +270,15 @@ export async function applyGitFallback(hunk: Hunk, content: string[]): Promise<E
console.error("Git fallback strategy failed:", error)
return { confidence: 0, result: content, strategy: "git-fallback" }
} finally {
if (tmpDir) {
tmpDir.removeCallback()
if (tmpGitPath) {
await adapter.rmdir(tmpGitPath, true);
}
}
}
// Main edit function that tries strategies sequentially
export async function applyEdit(
app: App,
hunk: Hunk,
content: string[],
matchPosition: number,
@ -275,14 +290,14 @@ export async function applyEdit(
console.log(
`Search confidence (${confidence}) below minimum threshold (${confidenceThreshold}), trying git fallback...`,
)
return applyGitFallback(hunk, content)
return applyGitFallback(app, hunk, content)
}
// Try each strategy in sequence until one succeeds
const strategies = [
{ name: "dmp", apply: () => applyDMP(hunk, content, matchPosition) },
{ name: "context", apply: () => applyContextMatching(hunk, content, matchPosition) },
{ name: "git-fallback", apply: () => applyGitFallback(hunk, content) },
{ name: "git-fallback", apply: () => applyGitFallback(app, hunk, content) },
]
// Try strategies sequentially until one succeeds

View File

@ -1,18 +1,33 @@
import { Diff, Hunk, Change } from "./types"
import { findBestMatch, prepareSearchString } from "./search-strategies"
import { applyEdit } from "./edit-strategies"
import { App } from 'obsidian'
import { DiffResult, DiffStrategy } from "../../types"
import { applyEdit } from "./edit-strategies"
import { findBestMatch, prepareSearchString } from "./search-strategies"
import { Change, Diff, Hunk } from "./types"
// 中文引号转英文引号
export function convertQuotes(str: string) {
return str.replace(/[“”]/g, '"');
}
export class NewUnifiedDiffStrategy implements DiffStrategy {
private readonly confidenceThreshold: number
private app: App
constructor(confidenceThreshold: number = 1) {
getName(): string {
return "NewUnified"
}
constructor(app: App, confidenceThreshold: number = 1) {
this.app = app
this.confidenceThreshold = Math.max(confidenceThreshold, 0.8)
}
private parseUnifiedDiff(diff: string): Diff {
const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes
const lines = diff.split("\n")
// console.log("lines: ", lines)
const hunks: Hunk[] = []
let currentHunk: Hunk | null = null
@ -60,7 +75,7 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
}
const content = line.slice(1)
const indentMatch = content.match(/^(\s*)/)
const indentMatch = /^(\s*)/.exec(content)
const indent = indentMatch ? indentMatch[0] : ""
const trimmedContent = content.slice(indent.length)
@ -85,6 +100,8 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
indent,
originalLine: content,
})
} else if (line.startsWith("reason: ")) {
// ignore reason
} else {
const finalContent = trimmedContent ? " " + trimmedContent : " "
currentHunk.changes.push({
@ -108,9 +125,9 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
}
getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
return `# apply_diff Tool - Generate Precise Code Changes
return `# apply_diff Tool - Generate Precise Markdown Changes
Generate a unified diff that can be cleanly applied to modify code files.
Generate a unified diff that can be cleanly applied to modify markdown files.
## Step-by-Step Instructions:
@ -120,47 +137,45 @@ Generate a unified diff that can be cleanly applied to modify code files.
2. For each change section:
- Begin with "@@ ... @@" separator line without line numbers
- Include 2-3 lines of context before and after changes
- Mark removed lines with "-"
- Mark added lines with "+"
- Preserve exact indentation
- Mark added lines with "+" prefix (without line numbers)
- Mark removed lines with "-" prefix (without line numbers)
- Mark reason with "reason: "
- Preserve exact spacing and formatting
3. Group related changes:
- Keep related modifications in the same hunk
- Start new hunks for logically separate changes
- When modifying functions/methods, include the entire block
## Requirements:
1. MUST include exact indentation
2. MUST include sufficient context for unique matching
3. MUST group related changes together
4. MUST use proper unified diff format
5. MUST NOT include timestamps in file headers
6. MUST NOT include line numbers in the @@ header
1. MUST include reason, avoid unnecessary modifications
2. MUST include exact spacing and formatting
3. MUST include sufficient context for unique matching
4. MUST group related changes together
5. MUST use proper unified diff format
6. MUST NOT include timestamps in file headers
7. MUST NOT include line numbers in the @@ header
8. MUST NOT include line numbers in the added lines and removed lines
## Examples:
Good diff (follows all requirements):
\`\`\`diff
--- src/utils.ts
+++ src/utils.ts
--- docs/example.md
+++ docs/example.md
@@ ... @@
def calculate_total(items):
- total = 0
- for item in items:
- total += item.price
+ return sum(item.price for item in items)
-old content
+new content
reason: change reason
\`\`\`
Bad diff (violates requirements #1 and #2):
Bad diff (violates requirements #8)
\`\`\`diff
--- src/utils.ts
+++ src/utils.ts
--- docs/example.md
+++ docs/example.md
@@ ... @@
-total = 0
-for item in items:
+return sum(item.price for item in items)
- 6 | old content
+ 6 | new content
\`\`\`
Parameters:
@ -169,7 +184,7 @@ Parameters:
Usage:
<apply_diff>
<path>path/to/file.ext</path>
<path>path/to/file.md</path>
<diff>
Your diff here
</diff>
@ -236,7 +251,7 @@ Your diff here
endLine?: number,
): Promise<DiffResult> {
const parsedDiff = this.parseUnifiedDiff(diffContent)
const originalLines = originalContent.split("\n")
const originalLines = convertQuotes(originalContent).split("\n")
let result = [...originalLines]
if (!parsedDiff.hunks.length) {
@ -247,13 +262,12 @@ Your diff here
}
for (const hunk of parsedDiff.hunks) {
const contextStr = prepareSearchString(hunk.changes)
const contextStr = convertQuotes(prepareSearchString(hunk.changes))
const {
index: matchPosition,
confidence,
strategy,
} = findBestMatch(contextStr, result, 0, this.confidenceThreshold)
if (confidence < this.confidenceThreshold) {
console.log("Full hunk application failed, trying sub-hunks strategy")
// Try splitting the hunk into smaller hunks
@ -267,6 +281,7 @@ Your diff here
if (subSearchResult.confidence >= this.confidenceThreshold) {
const subEditResult = await applyEdit(
this.app,
subHunk,
subHunkResult,
subSearchResult.index,
@ -324,7 +339,14 @@ Your diff here
return { success: false, error: errorMsg }
}
const editResult = await applyEdit(hunk, result, matchPosition, confidence, this.confidenceThreshold)
const editResult = await applyEdit(
this.app,
hunk,
result,
matchPosition,
confidence,
this.confidenceThreshold,
)
if (editResult.confidence >= this.confidenceThreshold) {
result = editResult.result
} else {

View File

@ -1,6 +1,7 @@
import { compareTwoStrings } from "string-similarity"
import { closest } from "fastest-levenshtein"
import { diff_match_patch } from "diff-match-patch"
import { closest } from "fastest-levenshtein"
import { compareTwoStrings } from "string-similarity"
import { Change, Hunk } from "./types"
export type SearchResult = {
@ -44,7 +45,7 @@ function evaluateContentUniqueness(searchStr: string, content: string[]): number
// Helper function to prepare search string from context
export function prepareSearchString(changes: Change[]): string {
const lines = changes.filter((c) => c.type === "context" || c.type === "remove").map((c) => c.originalLine)
const lines = changes.filter((c) => c.type === "remove").map((c) => c.content)
return lines.join("\n")
}
@ -198,12 +199,16 @@ export function findExactMatch(
startIndex: number = 0,
confidenceThreshold: number = 0.97,
): SearchResult {
// console.log("searchStr: ", searchStr)
// console.log("content: ", content)
const searchLines = searchStr.split("\n")
const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length)
const matches: (SearchResult & { windowIndex: number })[] = []
windows.forEach((windowData, windowIndex) => {
const windowStr = windowData.window.join("\n")
// console.log("searchStr: ", searchStr)
// console.log("windowStr:", windowStr)
const exactMatch = windowStr.indexOf(searchStr)
if (exactMatch !== -1) {
@ -399,10 +404,18 @@ export function findBestMatch(
for (const strategy of strategies) {
const result = strategy(searchStr, content, startIndex, confidenceThreshold)
if (searchStr === "由于年久失修,街区路面坑洼不平,污水横流,垃圾遍地,甚至可见弹痕血迹。") {
console.log("findBestMatch result: ", strategy.name, result)
}
if (result.confidence > bestResult.confidence) {
bestResult = result
}
}
// if (bestResult.confidence < 0.97) {
// console.log("searchStr: ", searchStr)
// console.log("content: ", content)
// console.log("findBestMatch result: ", bestResult)
// }
return bestResult
}

View File

@ -1,7 +1,8 @@
import { DiffStrategy, DiffResult } from "../types"
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
import { distance } from "fastest-levenshtein"
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text"
import { DiffResult, DiffStrategy } from "../types"
const BUFFER_LINES = 20 // Number of extra context lines to show before and after matches
function getSimilarity(original: string, search: string): number {
@ -31,6 +32,10 @@ export class SearchReplaceDiffStrategy implements DiffStrategy {
private fuzzyThreshold: number
private bufferLines: number
getName(): string {
return "SearchReplace"
}
constructor(fuzzyThreshold?: number, bufferLines?: number) {
// Use provided threshold or default to exact matching (1.0)
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
@ -225,14 +230,14 @@ Your search/replace content here
const originalContentSection =
startLine !== undefined && endLine !== undefined
? `\n\nOriginal Content:\n${addLineNumbers(
originalLines
.slice(
Math.max(0, startLine - 1 - this.bufferLines),
Math.min(originalLines.length, endLine + this.bufferLines),
)
.join("\n"),
Math.max(1, startLine - this.bufferLines),
)}`
originalLines
.slice(
Math.max(0, startLine - 1 - this.bufferLines),
Math.min(originalLines.length, endLine + this.bufferLines),
)
.join("\n"),
Math.max(1, startLine - this.bufferLines),
)}`
: `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}`
const bestMatchSection = bestMatchContent

View File

@ -3,20 +3,28 @@
*/
export type DiffResult =
| { success: true; content: string }
| {
success: false
error: string
details?: {
similarity?: number
threshold?: number
matchedRange?: { start: number; end: number }
searchContent?: string
bestMatch?: string
}
}
| { success: true; content: string; failParts?: DiffResult[] }
| ({
success: false
error?: string
details?: {
similarity?: number
threshold?: number
matchedRange?: { start: number; end: number }
searchContent?: string
bestMatch?: string
}
failParts?: DiffResult[]
} & ({ error: string } | { failParts: DiffResult[] }))
export interface DiffStrategy {
/**
* Get the name of this diff strategy for analytics and debugging
* @returns The name of the diff strategy
*/
getName(): string
/**
* Get the tool description for this diff strategy
* @param args The tool arguments including cwd and toolOptions

View File

@ -1,9 +1,14 @@
import { DiffStrategy } from "../../diff/DiffStrategy"
function getEditingInstructions(diffStrategy?: DiffStrategy, experiments?: Record<string, boolean>): string {
function getEditingInstructions(diffStrategy?: DiffStrategy): string {
const instructions: string[] = []
const availableTools: string[] = []
const experiments = {
insert_content: true,
search_and_replace: true,
}
// Collect available editing tools
if (diffStrategy) {
availableTools.push(
@ -90,7 +95,7 @@ RULES
- Your current obsidian directory is: ${cwd.toPosix()}
${getSearchInstructions(searchTool)}
- When creating new notes in Obsidian, organize them according to the existing vault structure unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the content logically, adhering to Obsidian conventions with appropriate frontmatter, headings, lists, and formatting. Unless otherwise specified, new notes should follow Markdown syntax with appropriate use of links ([[note name]]), tags (#tag), callouts, and other Obsidian-specific formatting.
${getEditingInstructions(diffStrategy, experiments)}
${getEditingInstructions(diffStrategy)}
- Be sure to consider the structure of the Obsidian vault (folders, naming conventions, note organization) when determining the appropriate format and content for new or modified notes. Also consider what files may be most relevant to accomplishing the task, for example examining backlinks, linked mentions, or tags would help you understand the relationships between notes, which you could incorporate into any content you write.
- When making changes to content, always consider the context within the broader vault. Ensure that your changes maintain existing links, tags, and references, and that they follow the user's established formatting standards and organization.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
@ -100,7 +105,7 @@ ${getEditingInstructions(diffStrategy, experiments)}
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the markdown" but instead something like "I've updated the markdown". It is important you be clear and technical in your messages.
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
- At the end of the first user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
- Pay special attention to the open tabs in environment_details, as they indicate which notes the user is currently working with and may be most relevant to their task. Similarly, the current file information shows which note is currently in focus and likely the primary subject of the user's request.
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to create a structured note, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.`
}

View File

@ -44,8 +44,8 @@ async function generatePrompt(
// throw new Error("Extension context is required for generating system prompt")
// }
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// // If diff is disabled, don't pass the diffStrategy
// const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// Get the full mode config to ensure we have the role definition
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
@ -54,7 +54,7 @@ async function generatePrompt(
const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
? getMcpServersSection(mcpHub, diffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
@ -67,7 +67,7 @@ ${getToolDescriptionsForMode(
cwd,
filesSearchMethod,
supportsComputerUse,
effectiveDiffStrategy,
diffStrategy,
browserViewportSize,
mcpHub,
customModeConfigs,
@ -91,7 +91,7 @@ ${getRulesSection(
cwd,
filesSearchMethod,
supportsComputerUse,
effectiveDiffStrategy,
diffStrategy,
experiments,
)}
@ -110,8 +110,8 @@ export const SYSTEM_PROMPT = async (
mode: Mode = defaultModeSlug,
filesSearchMethod: string = 'regex',
preferredLanguage?: string,
mcpHub?: McpHub,
diffStrategy?: DiffStrategy,
mcpHub?: McpHub,
browserViewportSize?: string,
customModePrompts?: CustomModePrompts,
customModes?: ModeConfig[],
@ -150,8 +150,8 @@ export const SYSTEM_PROMPT = async (
// ${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
// }
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// // If diff is disabled, don't pass the diffStrategy
// const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
return generatePrompt(
// context,
@ -160,7 +160,7 @@ export const SYSTEM_PROMPT = async (
currentMode.slug,
filesSearchMethod,
mcpHub,
effectiveDiffStrategy,
diffStrategy,
browserViewportSize,
promptComponent,
customModes,

View File

@ -1,4 +1,4 @@
export function getAskFollowupQuestionDescription(userLanguage: string): string {
export function getAskFollowupQuestionDescription(): string {
return `## ask_followup_question
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
@ -10,5 +10,6 @@ Usage:
Example: Requesting to ask the user for their preferred citation style for an academic document
<ask_followup_question>
<question>Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)?</question>`
<question>Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)?</question>
</ask_followup_question>`
}

View File

@ -18,6 +18,7 @@ Usage:
}
]</operations>
</insert_content>
Example: Insert a new section heading and paragraph
<insert_content>
<path>chapter1.md</path>

View File

@ -26,7 +26,7 @@ Usage:
]</operations>
</search_and_replace>
Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essay
Example 1: Replace "climate change" with "climate crisis" in lines 1-10 of an essay
<search_and_replace>
<path>essays/environmental-impact.md</path>
<operations>[
@ -38,7 +38,8 @@ Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essa
}
]</operations>
</search_and_replace>
Example: Update citation format throughout a document using regex
Example 2: Update citation format throughout a document using regex
<search_and_replace>
<path>research-paper.md</path>
<operations>[

View File

@ -16,17 +16,17 @@ Usage:
<query>Your search query here</query>
</search_web>
Examples1:
Example 1:
<search_web>
<query>capital of France population statistics 2023</query>
</search_web>
Examples2:
Example 2:
<search_web>
<query>"renewable energy" growth statistics Europe</query>
</search_web>
Examples3:
Example 3:
<search_web>
<query>react vs angular vs vue.js comparison</query>
</search_web>`

View File

@ -12,7 +12,7 @@ import { VectorManager } from './modules/vector/vector-manager'
// import { migrations } from './sql'
export class DBManager {
// private app: App
private app: App
// private dbPath: string
private db: PGliteWithLive | null = null
// private db: PgliteDatabase | null = null
@ -65,34 +65,34 @@ export class DBManager {
// })
// }
// private async loadExistingDatabase() {
// try {
// const databaseFileExists = await this.app.vault.adapter.exists(
// this.dbPath,
// )
// if (!databaseFileExists) {
// return null
// }
// const fileBuffer = await this.app.vault.adapter.readBinary(this.dbPath)
// const fileBlob = new Blob([fileBuffer], { type: 'application/x-gzip' })
// const { fsBundle, wasmModule, vectorExtensionBundlePath } =
// await this.loadPGliteResources()
// this.db = await PGlite.create({
// loadDataDir: fileBlob,
// fsBundle: fsBundle,
// wasmModule: wasmModule,
// extensions: {
// vector: vectorExtensionBundlePath,
// live
// },
// })
// // return drizzle(this.pgClient)
// } catch (error) {
// console.error('Error loading database:', error)
// console.log(this.dbPath)
// return null
// }
// }
private async loadExistingDatabase() {
try {
const databaseFileExists = await this.app.vault.adapter.exists(
this.dbPath,
)
if (!databaseFileExists) {
return null
}
const fileBuffer = await this.app.vault.adapter.readBinary(this.dbPath)
const fileBlob = new Blob([fileBuffer], { type: 'application/x-gzip' })
const { fsBundle, wasmModule, vectorExtensionBundlePath } =
await this.loadPGliteResources()
this.db = await PGlite.create({
loadDataDir: fileBlob,
fsBundle: fsBundle,
wasmModule: wasmModule,
extensions: {
vector: vectorExtensionBundlePath,
live
},
})
// return drizzle(this.pgClient)
} catch (error) {
console.error('Error loading database:', error)
console.log(this.dbPath)
return null
}
}
// private async migrateDatabase(): Promise<void> {
// if (!this.db) {

View File

@ -1,4 +1,5 @@
import { App } from 'obsidian'
import { Transaction } from '@electric-sql/pglite'
import { editorStateToPlainText } from '../../../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { ChatAssistantMessage, ChatConversationMeta, ChatMessage, ChatUserMessage } from '../../../types/chat'
@ -22,42 +23,44 @@ export class ConversationManager {
this.repository = new ConversationRepository(app, db)
}
async createConversation(id: string, title = 'New chat'): Promise<void> {
async createConversation(id: string, title = 'New chat', tx?: Transaction): Promise<void> {
const conversation = {
id,
title,
createdAt: new Date(),
updatedAt: new Date(),
}
await this.repository.create(conversation)
await this.repository.create(conversation, tx)
}
async saveConversation(id: string, messages: ChatMessage[]): Promise<void> {
const conversation = await this.repository.findById(id)
if (!conversation) {
let title = 'New chat'
if (messages.length > 0 && messages[0].role === 'user') {
const query = editorStateToPlainText(messages[0].content)
if (query.length > 20) {
title = `${query.slice(0, 20)}...`
} else {
title = query
async txCreateOrUpdateConversation(id: string, messages: ChatMessage[]): Promise<void> {
await this.repository.tx(async (tx) => {
const conversation = await this.repository.findById(id, tx)
if (!conversation) {
let title = 'New chat'
if (messages.length > 0 && messages[0].role === 'user') {
const query = editorStateToPlainText(messages[0].content)
if (query.length > 20) {
title = `${query.slice(0, 20)}...`
} else {
title = query
}
}
await this.createConversation(id, title, tx)
}
await this.createConversation(id, title)
}
// Delete existing messages
await this.repository.deleteAllMessagesFromConversation(id)
// Delete existing messages
await this.repository.deleteAllMessagesFromConversation(id, tx)
// Insert new messages
for (const message of messages) {
const insertMessage = this.serializeMessage(message, id)
await this.repository.createMessage(insertMessage)
}
// Insert new messages
for (const message of messages) {
const insertMessage = this.serializeMessage(message, id)
await this.repository.createMessage(insertMessage, tx)
}
// Update conversation timestamp
await this.repository.update(id, { updatedAt: new Date() })
// Update conversation timestamp
await this.repository.update(id, { updatedAt: new Date() }, tx)
})
}
async findConversation(id: string): Promise<ChatMessage[] | null> {

View File

@ -1,129 +1,139 @@
import { PGliteInterface } from '@electric-sql/pglite'
import { PGliteInterface, Transaction } from '@electric-sql/pglite'
import { App } from 'obsidian'
import {
InsertConversation,
InsertMessage,
SelectConversation,
SelectMessage,
InsertConversation,
InsertMessage,
SelectConversation,
SelectMessage,
} from '../../schema'
export class ConversationRepository {
private app: App
private db: PGliteInterface
private app: App
private db: PGliteInterface
constructor(app: App, db: PGliteInterface) {
this.app = app
this.db = db
}
constructor(app: App, db: PGliteInterface) {
this.app = app
this.db = db
}
async create(conversation: InsertConversation): Promise<SelectConversation> {
const result = await this.db.query<SelectConversation>(
`INSERT INTO conversations (id, title, created_at, updated_at)
async tx(callback: (tx: Transaction) => Promise<void>) {
await this.db.transaction(async (tx) => {
await callback(tx)
});
}
async create(conversation: InsertConversation, tx?: Transaction): Promise<SelectConversation> {
const result = await (tx ?? this.db).query<SelectConversation>(
`INSERT INTO conversations (id, title, created_at, updated_at)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
conversation.id,
conversation.title,
conversation.createdAt || new Date(),
conversation.updatedAt || new Date()
]
)
return result.rows[0]
}
[
conversation.id,
conversation.title,
conversation.createdAt || new Date(),
conversation.updatedAt || new Date()
]
)
return result.rows[0]
}
async createMessage(message: InsertMessage): Promise<SelectMessage> {
const result = await this.db.query<SelectMessage>(
`INSERT INTO messages (
async createMessage(message: InsertMessage, tx?: Transaction): Promise<SelectMessage> {
const result = await (tx ?? this.db).query<SelectMessage>(
`INSERT INTO messages (
id, conversation_id, apply_status, role, content, reasoning_content,
prompt_content, metadata, mentionables,
similarity_search_results, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
message.id,
message.conversationId,
message.apply_status,
message.role,
message.content,
message.reasoningContent,
message.promptContent,
message.metadata,
message.mentionables,
message.similaritySearchResults,
message.createdAt || new Date()
]
[
message.id,
message.conversationId,
message.apply_status,
message.role,
message.content,
message.reasoningContent,
message.promptContent,
message.metadata,
message.mentionables,
message.similaritySearchResults,
message.createdAt || new Date()
]
)
return result.rows[0]
}
console.log('createMessage: ', message.id, result)
return result.rows[0]
}
async findById(id: string): Promise<SelectConversation | undefined> {
const result = await this.db.query<SelectConversation>(
`SELECT * FROM conversations WHERE id = $1 LIMIT 1`,
[id]
)
return result.rows[0]
}
async findById(id: string, tx?: Transaction): Promise<SelectConversation | undefined> {
const result = await (tx ?? this.db).query<SelectConversation>(
`SELECT * FROM conversations WHERE id = $1 LIMIT 1`,
[id]
)
return result.rows[0]
}
async findMessagesByConversationId(conversationId: string): Promise<SelectMessage[]> {
const result = await this.db.query<SelectMessage>(
`SELECT * FROM messages
async findMessagesByConversationId(conversationId: string, tx?: Transaction): Promise<SelectMessage[]> {
const result = await (tx ?? this.db).query<SelectMessage>(
`SELECT * FROM messages
WHERE conversation_id = $1
ORDER BY created_at`,
[conversationId]
[conversationId]
)
return result.rows
}
return result.rows
}
async findAll(): Promise<SelectConversation[]> {
const result = await this.db.query<SelectConversation>(
`SELECT * FROM conversations ORDER BY created_at DESC`
)
return result.rows
}
async findAll(tx?: Transaction): Promise<SelectConversation[]> {
const result = await (tx ?? this.db).query<SelectConversation>(
`SELECT * FROM conversations ORDER BY created_at DESC`
)
return result.rows
}
async update(id: string, data: Partial<InsertConversation>): Promise<SelectConversation> {
const setClauses: string[] = []
const values: (string | Date)[] = []
let paramIndex = 1
async update(id: string, data: Partial<InsertConversation>, tx?: Transaction): Promise<SelectConversation> {
const setClauses: string[] = []
const values: (string | Date)[] = []
let paramIndex = 1
if (data.title !== undefined) {
setClauses.push(`title = $${paramIndex}`)
values.push(data.title)
paramIndex++
}
if (data.title !== undefined) {
setClauses.push(`title = $${paramIndex}`)
values.push(data.title)
paramIndex++
}
// Always update updated_at
setClauses.push(`updated_at = $${paramIndex}`)
values.push(new Date())
paramIndex++
// Always update updated_at
setClauses.push(`updated_at = $${paramIndex}`)
values.push(new Date())
paramIndex++
// Add id as the last parameter
values.push(id)
// Add id as the last parameter
values.push(id)
const result = await this.db.query<SelectConversation>(
`UPDATE conversations
const result = await (tx ?? this.db).query<SelectConversation>(
`UPDATE conversations
SET ${setClauses.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
)
return result.rows[0]
}
values
)
return result.rows[0]
}
async delete(id: string): Promise<boolean> {
const result = await this.db.query<SelectConversation>(
`DELETE FROM conversations WHERE id = $1 RETURNING *`,
[id]
)
return result.rows.length > 0
}
async delete(id: string, tx?: Transaction): Promise<boolean> {
const result = await (tx ?? this.db).query<SelectConversation>(
`DELETE FROM conversations WHERE id = $1 RETURNING *`,
[id]
)
return result.rows.length > 0
}
async deleteAllMessagesFromConversation(conversationId: string): Promise<void> {
await this.db.query(
`DELETE FROM messages WHERE conversation_id = $1`,
[conversationId]
)
}
async deleteAllMessagesFromConversation(conversationId: string, tx?: Transaction): Promise<void> {
const result = await (tx ?? this.db).query(
`DELETE FROM messages WHERE conversation_id = $1`,
[conversationId]
)
console.log('deleteAllMessagesFromConversation', conversationId, result)
return
}
}

View File

@ -36,12 +36,11 @@ export function useChatHistory(): UseChatHistory {
void fetchChatList()
}, [fetchChatList])
// 只新增消息
const createConversation = useCallback(
const createOrUpdateConversation = useCallback(
async (id: string, messages: ChatMessage[]): Promise<void> => {
const dbManager = await getManager()
const conversationManager = dbManager.getConversationManager()
await conversationManager.saveConversation(id, messages)
await conversationManager.txCreateOrUpdateConversation(id, messages)
},
[getManager],
)
@ -74,7 +73,7 @@ export function useChatHistory(): UseChatHistory {
)
return {
createOrUpdateConversation: createConversation,
createOrUpdateConversation,
deleteConversation,
getChatMessagesById,
updateConversationTitle,

View File

@ -7,6 +7,7 @@ import { ApplyView } from './ApplyView'
import { ChatView } from './ChatView'
import { ChatProps } from './components/chat-view/Chat'
import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants'
import { getDiffStrategy } from "./core/diff/DiffStrategy"
import { InlineEdit } from './core/edit/inline-edit-processor'
import { RAGEngine } from './core/rag/rag-engine'
import { DBManager } from './database/database-manager'
@ -29,61 +30,77 @@ import {
import { getMentionableBlockData } from './utils/obsidian'
import './utils/path'
// Remember to rename these classes and interfaces!
export default class InfioPlugin extends Plugin {
private metadataCacheUnloadFn: (() => void) | null = null
private activeLeafChangeUnloadFn: (() => void) | null = null
private dbManagerInitPromise: Promise<DBManager> | null = null
private ragEngineInitPromise: Promise<RAGEngine> | null = null
settings: InfioSettings
settingTab: InfioSettingTab
settingsListeners: ((newSettings: InfioSettings) => void)[] = []
private activeLeafChangeUnloadFn: (() => void) | null = null
private metadataCacheUnloadFn: (() => void) | null = null
initChatProps?: ChatProps
dbManager: DBManager | null = null
ragEngine: RAGEngine | null = null
inlineEdit: InlineEdit | null = null
private dbManagerInitPromise: Promise<DBManager> | null = null
private ragEngineInitPromise: Promise<RAGEngine> | null = null
// private pg: PGlite | null = null
diffStrategy?: DiffStrategy
async onload() {
// load settings
await this.loadSettings()
// Add settings tab
// add settings tab
this.settingTab = new InfioSettingTab(this.app, this)
this.addSettingTab(this.settingTab)
// create and init pglite db
// this.pg = await createAndInitDb()
// This creates an icon in the left ribbon.
// add icon to ribbon
this.addRibbonIcon('wand-sparkles', 'Open infio copilot', () =>
this.openChatView(),
)
// register views
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this))
this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf))
// Register markdown processor for ai blocks
// register markdown processor for Inline Edit
this.inlineEdit = new InlineEdit(this, this.settings);
this.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => {
this.inlineEdit?.Processor(source, el, ctx);
});
// Update inlineEdit when settings change
this.addSettingsListener((newSettings) => {
this.inlineEdit = new InlineEdit(this, newSettings);
});
// Setup event listener
// setup autocomplete event listener
const statusBar = StatusBar.fromApp(this);
const eventListener = EventListener.fromSettings(
this.settings,
statusBar,
this.app
);
// initialize diff strategy
this.diffStrategy = getDiffStrategy(
this.settings.chatModelId || "",
this.app,
this.settings.fuzzyMatchThreshold,
this.settings.experimentalDiffStrategy,
this.settings.multiSearchReplaceDiffStrategy,
)
// add settings change listener
this.addSettingsListener((newSettings) => {
// Update inlineEdit when settings change
this.inlineEdit = new InlineEdit(this, newSettings);
// Update autocomplete event listener when settings change
eventListener.handleSettingChanged(newSettings)
// Update diff strategy when settings change
this.diffStrategy = getDiffStrategy(
this.settings.chatModelId || "",
this.app,
this.settings.fuzzyMatchThreshold,
this.settings.experimentalDiffStrategy,
this.settings.multiSearchReplaceDiffStrategy,
)
});
// Setup render plugin
// setup autocomplete render plugin
this.registerEditorExtension([
InlineSuggestionState,
CompletionKeyWatcher(
@ -107,6 +124,7 @@ export default class InfioPlugin extends Plugin {
}
});
/// *** Event Listeners ***
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf?.view instanceof MarkdownView) {
@ -139,7 +157,7 @@ export default class InfioPlugin extends Plugin {
})
);
// This adds a simple command that can be triggered anywhere
/// *** Commands ***
this.addCommand({
id: 'open-new-chat',
name: 'Open new chat',
@ -337,7 +355,6 @@ export default class InfioPlugin extends Plugin {
}
onunload() {
// this.dbManager?.cleanup()
this.dbManager = null
}

View File

@ -64,6 +64,13 @@ export type SearchAndReplaceToolArgs = {
}[];
}
export type ApplyDiffToolArgs = {
type: 'apply_diff';
filepath: string;
diff: string;
finish?: boolean;
}
export type SearchWebToolArgs = {
type: 'search_web';
query: string;
@ -83,4 +90,4 @@ export type SwitchModeToolArgs = {
finish?: boolean;
}
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs;
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs;

View File

@ -219,6 +219,15 @@ export const InfioSettingsSchema = z.object({
embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google),
embeddingModelId: z.string().catch(''),
// fuzzyMatchThreshold
fuzzyMatchThreshold: z.number().catch(0.85),
// experimentalDiffStrategy
experimentalDiffStrategy: z.boolean().catch(false),
// multiSearchReplaceDiffStrategy
multiSearchReplaceDiffStrategy: z.boolean().catch(true),
// Mode
mode: z.string().catch('ask'),

215
src/utils/extract-text.ts Normal file
View File

@ -0,0 +1,215 @@
// import * as path from "path"
// // @ts-ignore-next-line
// import pdf from "pdf-parse/lib/pdf-parse"
// import mammoth from "mammoth"
// import fs from "fs/promises"
// import { isBinaryFile } from "isbinaryfile"
// export async function extractTextFromFile(filePath: string): Promise<string> {
// try {
// await fs.access(filePath)
// } catch (error) {
// throw new Error(`File not found: ${filePath}`)
// }
// const fileExtension = path.extname(filePath).toLowerCase()
// switch (fileExtension) {
// case ".pdf":
// return extractTextFromPDF(filePath)
// case ".docx":
// return extractTextFromDOCX(filePath)
// case ".ipynb":
// return extractTextFromIPYNB(filePath)
// default:
// const isBinary = await isBinaryFile(filePath).catch(() => false)
// if (!isBinary) {
// return addLineNumbers(await fs.readFile(filePath, "utf8"))
// } else {
// throw new Error(`Cannot read text for file type: ${fileExtension}`)
// }
// }
// }
// async function extractTextFromPDF(filePath: string): Promise<string> {
// const dataBuffer = await fs.readFile(filePath)
// const data = await pdf(dataBuffer)
// return addLineNumbers(data.text)
// }
// async function extractTextFromDOCX(filePath: string): Promise<string> {
// const result = await mammoth.extractRawText({ path: filePath })
// return addLineNumbers(result.value)
// }
// async function extractTextFromIPYNB(filePath: string): Promise<string> {
// const data = await fs.readFile(filePath, "utf8")
// const notebook = JSON.parse(data)
// let extractedText = ""
// for (const cell of notebook.cells) {
// if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
// extractedText += cell.source.join("\n") + "\n"
// }
// }
// return addLineNumbers(extractedText)
// }
export function addLineNumbers(content: string, startLine: number = 1): string {
const lines = content.split("\n")
const maxLineNumberWidth = String(startLine + lines.length - 1).length
return lines
.map((line, index) => {
const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, " ")
return `${lineNumber} | ${line}`
})
.join("\n")
}
// Checks if every line in the content has line numbers prefixed (e.g., "1 | content" or "123 | content")
// Line numbers must be followed by a single pipe character (not double pipes)
export function everyLineHasLineNumbers(content: string): boolean {
const lines = content.split(/\r?\n/)
return lines.length > 0 && lines.every((line) => /^\s*\d+\s+\|(?!\|)/.test(line))
}
// Strips line numbers from content while preserving the actual content
// Handles formats like "1 | content", " 12 | content", "123 | content"
// Preserves content that naturally starts with pipe characters
export function stripLineNumbers(content: string): string {
// Split into lines to handle each line individually
const lines = content.split(/\r?\n/)
// Process each line
const processedLines = lines.map((line) => {
// Match line number pattern and capture everything after the pipe
const match = line.match(/^\s*\d+\s+\|(?!\|)\s?(.*)$/)
return match ? match[1] : line
})
// Join back with original line endings
const lineEnding = content.includes("\r\n") ? "\r\n" : "\n"
return processedLines.join(lineEnding)
}
// /**
// * Truncates multi-line output while preserving context from both the beginning and end.
// * When truncation is needed, it keeps 20% of the lines from the start and 80% from the end,
// * with a clear indicator of how many lines were omitted in between.
// *
// * @param content The multi-line string to truncate
// * @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content
// * @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed
// *
// * @example
// * // With 10 line limit on 25 lines of content:
// * // - Keeps first 2 lines (20% of 10)
// * // - Keeps last 8 lines (80% of 10)
// * // - Adds "[...15 lines omitted...]" in between
// */
// export function truncateOutput(content: string, lineLimit?: number): string {
// if (!lineLimit) {
// return content
// }
// // Count total lines
// let totalLines = 0
// let pos = -1
// while ((pos = content.indexOf("\n", pos + 1)) !== -1) {
// totalLines++
// }
// totalLines++ // Account for last line without newline
// if (totalLines <= lineLimit) {
// return content
// }
// const beforeLimit = Math.floor(lineLimit * 0.2) // 20% of lines before
// const afterLimit = lineLimit - beforeLimit // remaining 80% after
// // Find start section end position
// let startEndPos = -1
// let lineCount = 0
// pos = 0
// while (lineCount < beforeLimit && (pos = content.indexOf("\n", pos)) !== -1) {
// startEndPos = pos
// lineCount++
// pos++
// }
// // Find end section start position
// let endStartPos = content.length
// lineCount = 0
// pos = content.length
// while (lineCount < afterLimit && (pos = content.lastIndexOf("\n", pos - 1)) !== -1) {
// endStartPos = pos + 1 // Start after the newline
// lineCount++
// }
// const omittedLines = totalLines - lineLimit
// const startSection = content.slice(0, startEndPos + 1)
// const endSection = content.slice(endStartPos)
// return startSection + `\n[...${omittedLines} lines omitted...]\n\n` + endSection
// }
// /**
// * Applies run-length encoding to compress repeated lines in text.
// * Only compresses when the compression description is shorter than the repeated content.
// *
// * @param content The text content to compress
// * @returns The compressed text with run-length encoding applied
// */
// export function applyRunLengthEncoding(content: string): string {
// if (!content) {
// return content
// }
// let result = ""
// let pos = 0
// let repeatCount = 0
// let prevLine = null
// let firstOccurrence = true
// while (pos < content.length) {
// const nextNewlineIdx = content.indexOf("\n", pos)
// const currentLine = nextNewlineIdx === -1 ? content.slice(pos) : content.slice(pos, nextNewlineIdx + 1)
// if (prevLine === null) {
// prevLine = currentLine
// } else if (currentLine === prevLine) {
// repeatCount++
// } else {
// if (repeatCount > 0) {
// const compressionDesc = `<previous line repeated ${repeatCount} additional times>\n`
// if (compressionDesc.length < prevLine.length * (repeatCount + 1)) {
// result += prevLine + compressionDesc
// } else {
// for (let i = 0; i <= repeatCount; i++) {
// result += prevLine
// }
// }
// repeatCount = 0
// } else {
// result += prevLine
// }
// prevLine = currentLine
// }
// pos = nextNewlineIdx === -1 ? content.length : nextNewlineIdx + 1
// }
// if (repeatCount > 0 && prevLine !== null) {
// const compressionDesc = `<previous line repeated ${repeatCount} additional times>\n`
// if (compressionDesc.length < prevLine.length * repeatCount) {
// result += prevLine + compressionDesc
// } else {
// for (let i = 0; i <= repeatCount; i++) {
// result += prevLine
// }
// }
// } else if (prevLine !== null) {
// result += prevLine
// }
// return result
// }

View File

@ -33,6 +33,7 @@ export type ParsedMsgBlock =
} | {
type: 'search_and_replace'
path: string
content: string
operations: {
search: string
replace: string
@ -43,6 +44,11 @@ export type ParsedMsgBlock =
regex_flags?: string
}[]
finish: boolean
} | {
type: 'apply_diff'
path: string
diff: string
finish: boolean
} | {
type: 'ask_followup_question'
question: string,
@ -224,7 +230,7 @@ export function parseMsgBlocks(
}
let path: string | undefined
let regex: string | undefined
for (const childNode of node.childNodes) {
if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) {
path = childNode.childNodes[0].value
@ -361,6 +367,7 @@ export function parseMsgBlocks(
}
let path: string | undefined
let operations = []
let content: string = ''
// 处理子标签
for (const childNode of node.childNodes) {
@ -368,8 +375,8 @@ export function parseMsgBlocks(
path = childNode.childNodes[0].value
} else if (childNode.nodeName === 'operations' && childNode.childNodes.length > 0) {
try {
const operationsJson = childNode.childNodes[0].value
operations = JSON5.parse(operationsJson)
content = childNode.childNodes[0].value
operations = JSON5.parse(content)
} catch (error) {
console.error('Failed to parse operations JSON', error)
}
@ -379,10 +386,41 @@ export function parseMsgBlocks(
parsedResult.push({
type: 'search_and_replace',
path,
content,
operations,
finish: node.sourceCodeLocation.endTag !== undefined
})
lastEndOffset = endOffset
} else if (node.nodeName === 'apply_diff') {
if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined')
}
const startOffset = node.sourceCodeLocation.startOffset
const endOffset = node.sourceCodeLocation.endOffset
if (startOffset > lastEndOffset) {
parsedResult.push({
type: 'string',
content: input.slice(lastEndOffset, startOffset),
})
}
let path: string | undefined
let diff: string | undefined
for (const childNode of node.childNodes) {
if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) {
path = childNode.childNodes[0].value
} else if (childNode.nodeName === 'diff' && childNode.childNodes.length > 0) {
diff = childNode.childNodes[0].value
}
}
parsedResult.push({
type: 'apply_diff',
path,
diff,
finish: node.sourceCodeLocation.endTag !== undefined
})
lastEndOffset = endOffset
} else if (node.nodeName === 'attempt_completion') {
if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined')
@ -443,10 +481,10 @@ export function parseMsgBlocks(
content: input.slice(lastEndOffset, startOffset),
})
}
let mode: string = ''
let reason: string = ''
for (const childNode of node.childNodes) {
if (childNode.nodeName === 'mode_slug' && childNode.childNodes.length > 0) {
// @ts-ignore - 忽略 value 属性的类型错误
@ -456,7 +494,7 @@ export function parseMsgBlocks(
reason = childNode.childNodes[0].value
}
}
parsedResult.push({
type: 'switch_mode',
mode,
@ -500,9 +538,9 @@ export function parseMsgBlocks(
content: input.slice(lastEndOffset, startOffset),
})
}
let urls: string[] = []
for (const childNode of node.childNodes) {
if (childNode.nodeName === 'urls' && childNode.childNodes.length > 0) {
try {
@ -516,7 +554,7 @@ export function parseMsgBlocks(
}
}
}
parsedResult.push({
type: 'fetch_urls_content',
urls,

View File

@ -1,7 +1,8 @@
import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, htmlToMarkdown, requestUrl, getLanguage } from 'obsidian'
import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, getLanguage, htmlToMarkdown, requestUrl } from 'obsidian'
import { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { QueryProgressState } from '../components/chat-view/QueryProgress'
import { DiffStrategy } from '../core/diff/DiffStrategy'
import { SYSTEM_PROMPT } from '../core/prompts/system'
import { RAGEngine } from '../core/rag/rag-engine'
import { SelectVector } from '../database/schema'
@ -113,7 +114,7 @@ export class PromptGenerator {
private getRagEngine: () => Promise<RAGEngine>
private app: App
private settings: InfioSettings
private diffStrategy: DiffStrategy
private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = {
role: 'assistant',
content: '',
@ -123,10 +124,12 @@ export class PromptGenerator {
getRagEngine: () => Promise<RAGEngine>,
app: App,
settings: InfioSettings,
diffStrategy?: DiffStrategy,
) {
this.getRagEngine = getRagEngine
this.app = app
this.settings = settings
this.diffStrategy = diffStrategy
}
public async generateRequestMessages({
@ -165,7 +168,7 @@ export class PromptGenerator {
similaritySearchResults,
},
]
console.log('this.settings.mode', this.settings.mode)
let filesSearchMethod = this.settings.filesSearchMethod
if (filesSearchMethod === 'auto' && this.settings.embeddingModelId && this.settings.embeddingModelId !== '') {
filesSearchMethod = 'semantic'
@ -173,10 +176,8 @@ export class PromptGenerator {
filesSearchMethod = 'regex'
}
console.log('filesSearchMethod: ', filesSearchMethod)
const userLanguage = getFullLanguageName(getLanguage())
console.log(' current user language: ', userLanguage)
const systemMessage = await this.getSystemMessageNew(this.settings.mode, filesSearchMethod, userLanguage)
const requestMessages: RequestMessage[] = [
@ -466,7 +467,7 @@ export class PromptGenerator {
}
private async getSystemMessageNew(mode: Mode, filesSearchMethod: string, preferredLanguage: string): Promise<RequestMessage> {
const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false, mode, filesSearchMethod, preferredLanguage)
const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false, mode, filesSearchMethod, preferredLanguage, this.diffStrategy)
return {
role: 'system',