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', 'fs',
'obsidian', 'obsidian',
'electron', 'electron',
'path',
'moment',
'node:events',
'child_process',
'@codemirror/autocomplete', '@codemirror/autocomplete',
'@codemirror/collab', '@codemirror/collab',
'@codemirror/commands', '@codemirror/commands',

View File

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

202
pnpm-lock.yaml generated
View File

@ -59,6 +59,9 @@ importers:
diff: diff:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
diff-match-patch:
specifier: ^1.0.5
version: 1.0.5
drizzle-orm: drizzle-orm:
specifier: ^0.35.2 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) 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: exponential-backoff:
specifier: ^3.1.1 specifier: ^3.1.1
version: 3.1.2 version: 3.1.2
fastest-levenshtein:
specifier: ^1.0.16
version: 1.0.16
fuse.js: fuse.js:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
@ -88,7 +94,7 @@ importers:
version: 2.2.3 version: 2.2.3
langchain: langchain:
specifier: ^0.3.2 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: lexical:
specifier: ^0.17.1 specifier: ^0.17.1
version: 0.17.1 version: 0.17.1
@ -146,6 +152,12 @@ importers:
remark-gfm: remark-gfm:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.1 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: uuid:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
@ -156,6 +168,9 @@ importers:
'@types/diff': '@types/diff':
specifier: ^5.2.3 specifier: ^5.2.3
version: 5.2.3 version: 5.2.3
'@types/diff-match-patch':
specifier: ^1.0.36
version: 1.0.36
'@types/jest': '@types/jest':
specifier: ^29.5.13 specifier: ^29.5.13
version: 29.5.14 version: 29.5.14
@ -1170,6 +1185,12 @@ packages:
'@keyv/serialize@1.0.2': '@keyv/serialize@1.0.2':
resolution: {integrity: sha512-+E/LyaAeuABniD/RvUezWVXKpeuvwLEA9//nE9952zBaOdBd2mQ3pPoM8cUe2X6IcMByfuSLzmYqnYshG60+HQ==} 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': '@langchain/core@0.3.40':
resolution: {integrity: sha512-RGhJOTzJv6H+3veBAnDlH2KXuZ68CXMEg6B6DPTzL3IGDyd+vLxXG4FIttzUwjdeQKjrrFBwlXpJDl7bkoApzQ==} resolution: {integrity: sha512-RGhJOTzJv6H+3veBAnDlH2KXuZ68CXMEg6B6DPTzL3IGDyd+vLxXG4FIttzUwjdeQKjrrFBwlXpJDl7bkoApzQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1677,6 +1698,9 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} 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': '@types/diff@5.2.3':
resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
@ -1851,6 +1875,14 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} 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: abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead deprecated: Use your platform's native atob() and btoa() methods instead
@ -2108,6 +2140,10 @@ packages:
character-reference-invalid@2.0.1: character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} 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: ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2162,6 +2198,9 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cosmiconfig@9.0.0: cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -2290,6 +2329,9 @@ packages:
devlop@1.1.0: devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 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: diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -2310,11 +2352,29 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'} 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: domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead 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: drizzle-kit@0.26.2:
resolution: {integrity: sha512-cMq8omEKywjIy5KcqUo6LvEFxkl8/zYHsgYjFVXjmPWWtuW4blcz+YW9+oIhoaALgs2ebRjzXwsJgN9i6P49Dw==} resolution: {integrity: sha512-cMq8omEKywjIy5KcqUo6LvEFxkl8/zYHsgYjFVXjmPWWtuW4blcz+YW9+oIhoaALgs2ebRjzXwsJgN9i6P49Dw==}
hasBin: true hasBin: true
@ -2428,6 +2488,15 @@ packages:
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 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: entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@ -2960,6 +3029,9 @@ packages:
html-void-elements@3.0.0: html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@3.7.3:
resolution: {integrity: sha512-XdyuCBH3/tTuRTCMFolbj5stKZek8FK7KVXm+aHYivHmXVo18jINvc2jR5zgFkp//z2KWl5vppTJ4DWhltYruA==}
http-proxy-agent@5.0.0: http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -3173,6 +3245,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
isarray@0.0.1:
resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==}
isarray@2.0.5: isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -3546,6 +3621,10 @@ packages:
lodash.truncate@4.4.2: lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash@2.4.2:
resolution: {integrity: sha512-Kak1hi6/hYHGVPmdyiZijoQyz5x2iGVzs6w9GYB/HiXEtylY7tIoYEROMjvM1d9nXJqPOrG2MNPMn01bJ+S0Rw==}
engines: {'0': node, '1': rhino}
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -4133,6 +4212,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@1.1.14:
resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4291,6 +4373,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-git@3.27.0:
resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==}
simple-wcswidth@1.0.1: simple-wcswidth@1.0.1:
resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==} resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==}
@ -4336,6 +4421,10 @@ packages:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'} 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: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4359,6 +4448,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
string_decoder@0.10.31:
resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==}
stringify-entities@4.0.4: stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@ -5549,6 +5641,14 @@ snapshots:
dependencies: dependencies:
buffer: 6.0.3 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))': '@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2))':
dependencies: dependencies:
'@cfworker/json-schema': 4.1.1 '@cfworker/json-schema': 4.1.1
@ -6186,6 +6286,8 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/diff-match-patch@1.0.36': {}
'@types/diff@5.2.3': {} '@types/diff@5.2.3': {}
'@types/eslint-utils@3.0.5': '@types/eslint-utils@3.0.5':
@ -6395,6 +6497,15 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@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: {} abab@2.0.6: {}
abort-controller@3.0.0: abort-controller@3.0.0:
@ -6706,6 +6817,14 @@ snapshots:
character-reference-invalid@2.0.1: {} 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: {} ci-info@3.9.0: {}
cjs-module-lexer@1.4.3: {} cjs-module-lexer@1.4.3: {}
@ -6748,6 +6867,9 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
core-util-is@1.0.3:
optional: true
cosmiconfig@9.0.0(typescript@4.9.5): cosmiconfig@9.0.0(typescript@4.9.5):
dependencies: dependencies:
env-paths: 2.2.1 env-paths: 2.2.1
@ -6867,6 +6989,8 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
diff-match-patch@1.0.5: {}
diff-sequences@29.6.3: {} diff-sequences@29.6.3: {}
diff@7.0.0: {} diff@7.0.0: {}
@ -6883,10 +7007,38 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 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: domexception@4.0.0:
dependencies: dependencies:
webidl-conversions: 7.0.0 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: drizzle-kit@0.26.2:
dependencies: dependencies:
'@drizzle-team/brocli': 0.10.2 '@drizzle-team/brocli': 0.10.2
@ -6920,6 +7072,15 @@ snapshots:
emoji-regex@8.0.0: {} 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: {} entities@4.5.0: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
@ -7725,6 +7886,15 @@ snapshots:
html-void-elements@3.0.0: {} 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: http-proxy-agent@5.0.0:
dependencies: dependencies:
'@tootallnate/once': 2.0.0 '@tootallnate/once': 2.0.0
@ -7930,6 +8100,9 @@ snapshots:
call-bound: 1.0.3 call-bound: 1.0.3
get-intrinsic: 1.2.7 get-intrinsic: 1.2.7
isarray@0.0.1:
optional: true
isarray@2.0.5: {} isarray@2.0.5: {}
isexe@2.0.0: {} isexe@2.0.0: {}
@ -8407,7 +8580,7 @@ snapshots:
known-css-properties@0.35.0: {} 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: dependencies:
'@langchain/core': 0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)) '@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) '@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) zod-to-json-schema: 3.24.1(zod@3.24.2)
optionalDependencies: optionalDependencies:
axios: 1.8.3 axios: 1.8.3
cheerio: 0.16.0
handlebars: 4.7.8 handlebars: 4.7.8
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@ -8479,6 +8653,9 @@ snapshots:
lodash.truncate@4.4.2: {} lodash.truncate@4.4.2: {}
lodash@2.4.2:
optional: true
lodash@4.17.21: {} lodash@4.17.21: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
@ -9265,6 +9442,14 @@ snapshots:
dependencies: dependencies:
loose-envify: 1.4.0 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: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -9468,6 +9653,14 @@ snapshots:
signal-exit@4.1.0: {} 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: {} simple-wcswidth@1.0.1: {}
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
@ -9509,6 +9702,8 @@ snapshots:
char-regex: 1.0.2 char-regex: 1.0.2
strip-ansi: 6.0.1 strip-ansi: 6.0.1
string-similarity@4.0.4: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
@ -9559,6 +9754,9 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
string_decoder@0.10.31:
optional: true
stringify-entities@4.0.4: stringify-entities@4.0.4:
dependencies: dependencies:
character-entities-html4: 2.1.0 character-entities-html4: 2.1.0

View File

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

View File

@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid'
import { ApplyViewState } from '../../ApplyView' import { ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants' import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext' import { useApp } from '../../contexts/AppContext'
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
import { useLLM } from '../../contexts/LLMContext' import { useLLM } from '../../contexts/LLMContext'
import { useRAG } from '../../contexts/RAGContext' import { useRAG } from '../../contexts/RAGContext'
import { useSettings } from '../../contexts/SettingsContext' import { useSettings } from '../../contexts/SettingsContext'
@ -93,6 +94,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp() const app = useApp()
const { settings, setSettings } = useSettings() const { settings, setSettings } = useSettings()
const { getRAGEngine } = useRAG() const { getRAGEngine } = useRAG()
const diffStrategy = useDiffStrategy()
const { const {
createOrUpdateConversation, createOrUpdateConversation,
@ -104,8 +106,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const { streamResponse, chatModel } = useLLM() const { streamResponse, chatModel } = useLLM()
const promptGenerator = useMemo(() => { const promptGenerator = useMemo(() => {
return new PromptGenerator(getRAGEngine, app, settings) return new PromptGenerator(getRAGEngine, app, settings, diffStrategy)
}, [getRAGEngine, app, settings]) }, [getRAGEngine, app, settings, diffStrategy])
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => { const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
const newMessage = getNewInputMessage(app) const newMessage = getNewInputMessage(app)
@ -382,7 +384,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
if (!applyRes) { if (!applyRes) {
throw new Error('Failed to apply edit changes') 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) => { return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({ app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE, type: APPLY_VIEW_TYPE,
@ -419,7 +421,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
fileContent, fileContent,
toolArgs.operations 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) => { return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => {
app.workspace.getLeaf(true).setViewState({ app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE, type: APPLY_VIEW_TYPE,
@ -449,6 +454,45 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
} satisfies ApplyViewState, } 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') { } else if (toolArgs.type === 'read_file') {
const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath) const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath)
const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`; 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 { Check, Loader2, Replace, X } from 'lucide-react'
import React from 'react' import React, { useMemo } from 'react'
import { useApp } from '../../contexts/AppContext' import { useApp } from '../../contexts/AppContext'
import { ApplyStatus, SearchAndReplaceToolArgs } from '../../types/apply' import { ApplyStatus, SearchAndReplaceToolArgs } from '../../types/apply'
import { openMarkdownFile } from '../../utils/obsidian' import { openMarkdownFile } from '../../utils/obsidian'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
export default function MarkdownSearchAndReplace({ export default function MarkdownSearchAndReplace({
applyStatus, applyStatus,
onApply, onApply,
path, path,
content,
operations, operations,
finish finish
}: { }: {
applyStatus: ApplyStatus applyStatus: ApplyStatus
onApply: (args: SearchAndReplaceToolArgs) => void onApply: (args: SearchAndReplaceToolArgs) => void
path: string, path: string,
content: string,
operations: SearchAndReplaceToolArgs['operations'], operations: SearchAndReplaceToolArgs['operations'],
finish: boolean finish: boolean
}) { }) {
const app = useApp() const app = useApp()
const { isDarkMode } = useDarkModeContext()
const [applying, setApplying] = React.useState(false) const [applying, setApplying] = React.useState(false)
@ -51,9 +56,13 @@ export default function MarkdownSearchAndReplace({
<div className={'infio-chat-code-block-header-button'}> <div className={'infio-chat-code-block-header-button'}>
<button <button
onClick={handleApply} 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 ? ( applying ? (
<> <>
<Loader2 className="spinner" size={14} /> Applying... <Loader2 className="spinner" size={14} /> Applying...
@ -73,6 +82,14 @@ export default function MarkdownSearchAndReplace({
</button> </button>
</div> </div>
</div> </div>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="markdown"
hasFilename={!!path}
wrapLines={true}
>
{content}
</MemoizedSyntaxHighlighterWrapper>
</div> </div>
) )
} }

View File

@ -7,6 +7,7 @@ import {
parseMsgBlocks, parseMsgBlocks,
} from '../../utils/parse-infio-block' } from '../../utils/parse-infio-block'
import MarkdownApplyDiffBlock from './MarkdownApplyDiffBlock'
import MarkdownEditFileBlock from './MarkdownEditFileBlock' import MarkdownEditFileBlock from './MarkdownEditFileBlock'
import MarkdownFetchUrlsContentBlock from './MarkdownFetchUrlsContentBlock' import MarkdownFetchUrlsContentBlock from './MarkdownFetchUrlsContentBlock'
import MarkdownListFilesBlock from './MarkdownListFilesBlock' import MarkdownListFilesBlock from './MarkdownListFilesBlock'
@ -18,6 +19,7 @@ import MarkdownSearchWebBlock from './MarkdownSearchWebBlock'
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock' import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock' import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock'
import MarkdownWithIcons from './MarkdownWithIcon' import MarkdownWithIcons from './MarkdownWithIcon'
function ReactMarkdown({ function ReactMarkdown({
applyStatus, applyStatus,
onApply, onApply,
@ -27,6 +29,7 @@ function ReactMarkdown({
onApply: (toolArgs: ToolArgs) => void onApply: (toolArgs: ToolArgs) => void
children: string children: string
}) { }) {
const blocks: ParsedMsgBlock[] = useMemo( const blocks: ParsedMsgBlock[] = useMemo(
() => parseMsgBlocks(children), () => parseMsgBlocks(children),
[children], [children],
@ -73,6 +76,7 @@ function ReactMarkdown({
applyStatus={applyStatus} applyStatus={applyStatus}
onApply={onApply} onApply={onApply}
path={block.path} path={block.path}
content={block.content}
operations={block.operations.map(op => ({ operations={block.operations.map(op => ({
search: op.search, search: op.search,
replace: op.replace, replace: op.replace,
@ -84,6 +88,16 @@ function ReactMarkdown({
}))} }))}
finish={block.finish} 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' ? ( ) : block.type === 'read_file' ? (
<MarkdownReadFileBlock <MarkdownReadFileBlock
key={"read-file-" + index} 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, options?: LLMOptions,
) => Promise<AsyncIterable<LLMResponseStreaming>> ) => Promise<AsyncIterable<LLMResponseStreaming>>
chatModel: LLMModel chatModel: LLMModel
applyModel: LLMModel // applyModel: LLMModel
} }
const LLMContext = createContext<LLMContextType | null>(null) const LLMContext = createContext<LLMContextType | null>(null)
@ -50,12 +50,12 @@ export function LLMProvider({ children }: PropsWithChildren) {
} }
}, [settings]) }, [settings])
const applyModel = useMemo((): LLMModel => { // const applyModel = useMemo((): LLMModel => {
return { // return {
provider: settings.applyModelProvider, // provider: settings.applyModelProvider,
modelId: settings.applyModelId, // modelId: settings.applyModelId,
} // }
}, [settings]) // }, [settings])
useEffect(() => { useEffect(() => {
const manager = new LLMManager(settings) const manager = new LLMManager(settings)
@ -92,7 +92,7 @@ export function LLMProvider({ children }: PropsWithChildren) {
return ( return (
<LLMContext.Provider <LLMContext.Provider
value={{ generateResponse, streamResponse, chatModel, applyModel }} value={{ generateResponse, streamResponse, chatModel }}
> >
{children} {children}
</LLMContext.Provider> </LLMContext.Provider>

View File

@ -1,7 +1,10 @@
import type { DiffStrategy } from "./types" import { App } from "obsidian"
import { UnifiedDiffStrategy } from "./strategies/unified"
import { SearchReplaceDiffStrategy } from "./strategies/search-replace" import { MultiSearchReplaceDiffStrategy } from "./strategies/multi-search-replace"
import { NewUnifiedDiffStrategy } from "./strategies/new-unified" 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 * 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') * @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( export function getDiffStrategy(
model: string, model: string,
app: App,
fuzzyMatchThreshold?: number, fuzzyMatchThreshold?: number,
experimentalDiffStrategy: boolean = false, experimentalDiffStrategy: boolean = false,
multiSearchReplaceDiffStrategy: boolean = false,
): DiffStrategy { ): DiffStrategy {
if (experimentalDiffStrategy) { // if (experimentalDiffStrategy) {
return new NewUnifiedDiffStrategy(fuzzyMatchThreshold) // return new NewUnifiedDiffStrategy(app, fuzzyMatchThreshold)
} // }
return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
// if (multiSearchReplaceDiffStrategy) {
// return new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold)
// } else {
// return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
// }
return new MultiSearchReplaceDiffStrategy(0.9)
} }
export { SearchReplaceDiffStrategy, UnifiedDiffStrategy }
export type { DiffStrategy } 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 { App, FileSystemAdapter, normalizePath } from "obsidian"
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 * as fs from "fs" import * as fs from "fs"
import * as path from "path"
// Helper function to infer indentation - simplified version import { diff_match_patch } from "diff-match-patch"
function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string { import simpleGit, { SimpleGit } from "simple-git"
// 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 import { validateEditResult } from "./search-strategies"
const contextLine = contextLines[0] import { EditResult, Hunk } from "./types"
if (contextLine) {
const contextMatch = contextLine.match(/^(\s+)/)
if (contextMatch) {
return contextMatch[1]
}
}
// 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 // Context matching edit strategy
export function applyContextMatching(hunk: Hunk, content: string[], matchPosition: number): EditResult { 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 // Git fallback strategy that works with full content
export async function applyGitFallback(hunk: Hunk, content: string[]): Promise<EditResult> { export async function applyGitFallback(app: App, hunk: Hunk, content: string[]): Promise<EditResult> {
let tmpDir: tmp.DirResult | undefined // 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 { try {
tmpDir = tmp.dirSync({ unsafeCleanup: true }) const exists = await adapter.exists(tmpGitPath);
const git: SimpleGit = simpleGit(tmpDir.name) 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.init()
await git.addConfig("user.name", "Temp") await git.addConfig("user.name", "Temp")
await git.addConfig("user.email", "temp@example.com") 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 const searchLines = hunk.changes
.filter((change) => change.type === "context" || change.type === "remove") .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) console.error("Git fallback strategy failed:", error)
return { confidence: 0, result: content, strategy: "git-fallback" } return { confidence: 0, result: content, strategy: "git-fallback" }
} finally { } finally {
if (tmpDir) { if (tmpGitPath) {
tmpDir.removeCallback() await adapter.rmdir(tmpGitPath, true);
} }
} }
} }
// Main edit function that tries strategies sequentially // Main edit function that tries strategies sequentially
export async function applyEdit( export async function applyEdit(
app: App,
hunk: Hunk, hunk: Hunk,
content: string[], content: string[],
matchPosition: number, matchPosition: number,
@ -275,14 +290,14 @@ export async function applyEdit(
console.log( console.log(
`Search confidence (${confidence}) below minimum threshold (${confidenceThreshold}), trying git fallback...`, `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 // Try each strategy in sequence until one succeeds
const strategies = [ const strategies = [
{ name: "dmp", apply: () => applyDMP(hunk, content, matchPosition) }, { name: "dmp", apply: () => applyDMP(hunk, content, matchPosition) },
{ name: "context", apply: () => applyContextMatching(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 // Try strategies sequentially until one succeeds

View File

@ -1,18 +1,33 @@
import { Diff, Hunk, Change } from "./types" import { App } from 'obsidian'
import { findBestMatch, prepareSearchString } from "./search-strategies"
import { applyEdit } from "./edit-strategies"
import { DiffResult, DiffStrategy } from "../../types" 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 { export class NewUnifiedDiffStrategy implements DiffStrategy {
private readonly confidenceThreshold: number 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) this.confidenceThreshold = Math.max(confidenceThreshold, 0.8)
} }
private parseUnifiedDiff(diff: string): Diff { private parseUnifiedDiff(diff: string): Diff {
const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes
const lines = diff.split("\n") const lines = diff.split("\n")
// console.log("lines: ", lines)
const hunks: Hunk[] = [] const hunks: Hunk[] = []
let currentHunk: Hunk | null = null let currentHunk: Hunk | null = null
@ -60,7 +75,7 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
} }
const content = line.slice(1) const content = line.slice(1)
const indentMatch = content.match(/^(\s*)/) const indentMatch = /^(\s*)/.exec(content)
const indent = indentMatch ? indentMatch[0] : "" const indent = indentMatch ? indentMatch[0] : ""
const trimmedContent = content.slice(indent.length) const trimmedContent = content.slice(indent.length)
@ -85,6 +100,8 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
indent, indent,
originalLine: content, originalLine: content,
}) })
} else if (line.startsWith("reason: ")) {
// ignore reason
} else { } else {
const finalContent = trimmedContent ? " " + trimmedContent : " " const finalContent = trimmedContent ? " " + trimmedContent : " "
currentHunk.changes.push({ currentHunk.changes.push({
@ -108,9 +125,9 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
} }
getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { 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: ## 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: 2. For each change section:
- Begin with "@@ ... @@" separator line without line numbers - Begin with "@@ ... @@" separator line without line numbers
- Include 2-3 lines of context before and after changes - Mark added lines with "+" prefix (without line numbers)
- Mark removed lines with "-" - Mark removed lines with "-" prefix (without line numbers)
- Mark added lines with "+" - Mark reason with "reason: "
- Preserve exact indentation - Preserve exact spacing and formatting
3. Group related changes: 3. Group related changes:
- Keep related modifications in the same hunk - Keep related modifications in the same hunk
- Start new hunks for logically separate changes - Start new hunks for logically separate changes
- When modifying functions/methods, include the entire block
## Requirements: ## Requirements:
1. MUST include exact indentation 1. MUST include reason, avoid unnecessary modifications
2. MUST include sufficient context for unique matching 2. MUST include exact spacing and formatting
3. MUST group related changes together 3. MUST include sufficient context for unique matching
4. MUST use proper unified diff format 4. MUST group related changes together
5. MUST NOT include timestamps in file headers 5. MUST use proper unified diff format
6. MUST NOT include line numbers in the @@ header 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: ## Examples:
Good diff (follows all requirements): Good diff (follows all requirements):
\`\`\`diff \`\`\`diff
--- src/utils.ts --- docs/example.md
+++ src/utils.ts +++ docs/example.md
@@ ... @@ @@ ... @@
def calculate_total(items): -old content
- total = 0 +new content
- for item in items: reason: change reason
- total += item.price
+ return sum(item.price for item in items)
\`\`\` \`\`\`
Bad diff (violates requirements #1 and #2): Bad diff (violates requirements #8)
\`\`\`diff \`\`\`diff
--- src/utils.ts --- docs/example.md
+++ src/utils.ts +++ docs/example.md
@@ ... @@ @@ ... @@
-total = 0 - 6 | old content
-for item in items: + 6 | new content
+return sum(item.price for item in items)
\`\`\` \`\`\`
Parameters: Parameters:
@ -169,7 +184,7 @@ Parameters:
Usage: Usage:
<apply_diff> <apply_diff>
<path>path/to/file.ext</path> <path>path/to/file.md</path>
<diff> <diff>
Your diff here Your diff here
</diff> </diff>
@ -236,7 +251,7 @@ Your diff here
endLine?: number, endLine?: number,
): Promise<DiffResult> { ): Promise<DiffResult> {
const parsedDiff = this.parseUnifiedDiff(diffContent) const parsedDiff = this.parseUnifiedDiff(diffContent)
const originalLines = originalContent.split("\n") const originalLines = convertQuotes(originalContent).split("\n")
let result = [...originalLines] let result = [...originalLines]
if (!parsedDiff.hunks.length) { if (!parsedDiff.hunks.length) {
@ -247,13 +262,12 @@ Your diff here
} }
for (const hunk of parsedDiff.hunks) { for (const hunk of parsedDiff.hunks) {
const contextStr = prepareSearchString(hunk.changes) const contextStr = convertQuotes(prepareSearchString(hunk.changes))
const { const {
index: matchPosition, index: matchPosition,
confidence, confidence,
strategy, strategy,
} = findBestMatch(contextStr, result, 0, this.confidenceThreshold) } = findBestMatch(contextStr, result, 0, this.confidenceThreshold)
if (confidence < this.confidenceThreshold) { if (confidence < this.confidenceThreshold) {
console.log("Full hunk application failed, trying sub-hunks strategy") console.log("Full hunk application failed, trying sub-hunks strategy")
// Try splitting the hunk into smaller hunks // Try splitting the hunk into smaller hunks
@ -267,6 +281,7 @@ Your diff here
if (subSearchResult.confidence >= this.confidenceThreshold) { if (subSearchResult.confidence >= this.confidenceThreshold) {
const subEditResult = await applyEdit( const subEditResult = await applyEdit(
this.app,
subHunk, subHunk,
subHunkResult, subHunkResult,
subSearchResult.index, subSearchResult.index,
@ -324,7 +339,14 @@ Your diff here
return { success: false, error: errorMsg } 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) { if (editResult.confidence >= this.confidenceThreshold) {
result = editResult.result result = editResult.result
} else { } 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 { diff_match_patch } from "diff-match-patch"
import { closest } from "fastest-levenshtein"
import { compareTwoStrings } from "string-similarity"
import { Change, Hunk } from "./types" import { Change, Hunk } from "./types"
export type SearchResult = { export type SearchResult = {
@ -44,7 +45,7 @@ function evaluateContentUniqueness(searchStr: string, content: string[]): number
// Helper function to prepare search string from context // Helper function to prepare search string from context
export function prepareSearchString(changes: Change[]): string { 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") return lines.join("\n")
} }
@ -198,12 +199,16 @@ export function findExactMatch(
startIndex: number = 0, startIndex: number = 0,
confidenceThreshold: number = 0.97, confidenceThreshold: number = 0.97,
): SearchResult { ): SearchResult {
// console.log("searchStr: ", searchStr)
// console.log("content: ", content)
const searchLines = searchStr.split("\n") const searchLines = searchStr.split("\n")
const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length) const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length)
const matches: (SearchResult & { windowIndex: number })[] = [] const matches: (SearchResult & { windowIndex: number })[] = []
windows.forEach((windowData, windowIndex) => { windows.forEach((windowData, windowIndex) => {
const windowStr = windowData.window.join("\n") const windowStr = windowData.window.join("\n")
// console.log("searchStr: ", searchStr)
// console.log("windowStr:", windowStr)
const exactMatch = windowStr.indexOf(searchStr) const exactMatch = windowStr.indexOf(searchStr)
if (exactMatch !== -1) { if (exactMatch !== -1) {
@ -399,10 +404,18 @@ export function findBestMatch(
for (const strategy of strategies) { for (const strategy of strategies) {
const result = strategy(searchStr, content, startIndex, confidenceThreshold) const result = strategy(searchStr, content, startIndex, confidenceThreshold)
if (searchStr === "由于年久失修,街区路面坑洼不平,污水横流,垃圾遍地,甚至可见弹痕血迹。") {
console.log("findBestMatch result: ", strategy.name, result)
}
if (result.confidence > bestResult.confidence) { if (result.confidence > bestResult.confidence) {
bestResult = result bestResult = result
} }
} }
// if (bestResult.confidence < 0.97) {
// console.log("searchStr: ", searchStr)
// console.log("content: ", content)
// console.log("findBestMatch result: ", bestResult)
// }
return 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 { 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 const BUFFER_LINES = 20 // Number of extra context lines to show before and after matches
function getSimilarity(original: string, search: string): number { function getSimilarity(original: string, search: string): number {
@ -31,6 +32,10 @@ export class SearchReplaceDiffStrategy implements DiffStrategy {
private fuzzyThreshold: number private fuzzyThreshold: number
private bufferLines: number private bufferLines: number
getName(): string {
return "SearchReplace"
}
constructor(fuzzyThreshold?: number, bufferLines?: number) { constructor(fuzzyThreshold?: number, bufferLines?: number) {
// Use provided threshold or default to exact matching (1.0) // Use provided threshold or default to exact matching (1.0)
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
@ -225,14 +230,14 @@ Your search/replace content here
const originalContentSection = const originalContentSection =
startLine !== undefined && endLine !== undefined startLine !== undefined && endLine !== undefined
? `\n\nOriginal Content:\n${addLineNumbers( ? `\n\nOriginal Content:\n${addLineNumbers(
originalLines originalLines
.slice( .slice(
Math.max(0, startLine - 1 - this.bufferLines), Math.max(0, startLine - 1 - this.bufferLines),
Math.min(originalLines.length, endLine + this.bufferLines), Math.min(originalLines.length, endLine + this.bufferLines),
) )
.join("\n"), .join("\n"),
Math.max(1, startLine - this.bufferLines), Math.max(1, startLine - this.bufferLines),
)}` )}`
: `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}` : `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}`
const bestMatchSection = bestMatchContent const bestMatchSection = bestMatchContent

View File

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

View File

@ -1,9 +1,14 @@
import { DiffStrategy } from "../../diff/DiffStrategy" import { DiffStrategy } from "../../diff/DiffStrategy"
function getEditingInstructions(diffStrategy?: DiffStrategy, experiments?: Record<string, boolean>): string { function getEditingInstructions(diffStrategy?: DiffStrategy): string {
const instructions: string[] = [] const instructions: string[] = []
const availableTools: string[] = [] const availableTools: string[] = []
const experiments = {
insert_content: true,
search_and_replace: true,
}
// Collect available editing tools // Collect available editing tools
if (diffStrategy) { if (diffStrategy) {
availableTools.push( availableTools.push(
@ -90,7 +95,7 @@ RULES
- Your current obsidian directory is: ${cwd.toPosix()} - Your current obsidian directory is: ${cwd.toPosix()}
${getSearchInstructions(searchTool)} ${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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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.` - 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") // throw new Error("Extension context is required for generating system prompt")
// } // }
// If diff is disabled, don't pass the diffStrategy // // If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined // const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// Get the full mode config to ensure we have the role definition // 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] 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([ const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(), getModesSection(),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) ? getMcpServersSection(mcpHub, diffStrategy, enableMcpServerCreation)
: Promise.resolve(""), : Promise.resolve(""),
]) ])
@ -67,7 +67,7 @@ ${getToolDescriptionsForMode(
cwd, cwd,
filesSearchMethod, filesSearchMethod,
supportsComputerUse, supportsComputerUse,
effectiveDiffStrategy, diffStrategy,
browserViewportSize, browserViewportSize,
mcpHub, mcpHub,
customModeConfigs, customModeConfigs,
@ -91,7 +91,7 @@ ${getRulesSection(
cwd, cwd,
filesSearchMethod, filesSearchMethod,
supportsComputerUse, supportsComputerUse,
effectiveDiffStrategy, diffStrategy,
experiments, experiments,
)} )}
@ -110,8 +110,8 @@ export const SYSTEM_PROMPT = async (
mode: Mode = defaultModeSlug, mode: Mode = defaultModeSlug,
filesSearchMethod: string = 'regex', filesSearchMethod: string = 'regex',
preferredLanguage?: string, preferredLanguage?: string,
mcpHub?: McpHub,
diffStrategy?: DiffStrategy, diffStrategy?: DiffStrategy,
mcpHub?: McpHub,
browserViewportSize?: string, browserViewportSize?: string,
customModePrompts?: CustomModePrompts, customModePrompts?: CustomModePrompts,
customModes?: ModeConfig[], customModes?: ModeConfig[],
@ -150,8 +150,8 @@ export const SYSTEM_PROMPT = async (
// ${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}` // ${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
// } // }
// If diff is disabled, don't pass the diffStrategy // // If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined // const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
return generatePrompt( return generatePrompt(
// context, // context,
@ -160,7 +160,7 @@ export const SYSTEM_PROMPT = async (
currentMode.slug, currentMode.slug,
filesSearchMethod, filesSearchMethod,
mcpHub, mcpHub,
effectiveDiffStrategy, diffStrategy,
browserViewportSize, browserViewportSize,
promptComponent, promptComponent,
customModes, customModes,

View File

@ -1,4 +1,4 @@
export function getAskFollowupQuestionDescription(userLanguage: string): string { export function getAskFollowupQuestionDescription(): string {
return `## ask_followup_question 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. 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: Parameters:
@ -10,5 +10,6 @@ Usage:
Example: Requesting to ask the user for their preferred citation style for an academic document Example: Requesting to ask the user for their preferred citation style for an academic document
<ask_followup_question> <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> ]</operations>
</insert_content> </insert_content>
Example: Insert a new section heading and paragraph Example: Insert a new section heading and paragraph
<insert_content> <insert_content>
<path>chapter1.md</path> <path>chapter1.md</path>

View File

@ -26,7 +26,7 @@ Usage:
]</operations> ]</operations>
</search_and_replace> </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> <search_and_replace>
<path>essays/environmental-impact.md</path> <path>essays/environmental-impact.md</path>
<operations>[ <operations>[
@ -38,7 +38,8 @@ Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essa
} }
]</operations> ]</operations>
</search_and_replace> </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> <search_and_replace>
<path>research-paper.md</path> <path>research-paper.md</path>
<operations>[ <operations>[

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { App } from 'obsidian' 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 { editorStateToPlainText } from '../../../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { ChatAssistantMessage, ChatConversationMeta, ChatMessage, ChatUserMessage } from '../../../types/chat' import { ChatAssistantMessage, ChatConversationMeta, ChatMessage, ChatUserMessage } from '../../../types/chat'
@ -22,42 +23,44 @@ export class ConversationManager {
this.repository = new ConversationRepository(app, db) 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 = { const conversation = {
id, id,
title, title,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
} }
await this.repository.create(conversation) await this.repository.create(conversation, tx)
} }
async saveConversation(id: string, messages: ChatMessage[]): Promise<void> { async txCreateOrUpdateConversation(id: string, messages: ChatMessage[]): Promise<void> {
const conversation = await this.repository.findById(id) await this.repository.tx(async (tx) => {
if (!conversation) { const conversation = await this.repository.findById(id, tx)
let title = 'New chat' if (!conversation) {
if (messages.length > 0 && messages[0].role === 'user') { let title = 'New chat'
const query = editorStateToPlainText(messages[0].content) if (messages.length > 0 && messages[0].role === 'user') {
if (query.length > 20) { const query = editorStateToPlainText(messages[0].content)
title = `${query.slice(0, 20)}...` if (query.length > 20) {
} else { title = `${query.slice(0, 20)}...`
title = query } else {
title = query
}
} }
await this.createConversation(id, title, tx)
} }
await this.createConversation(id, title)
}
// Delete existing messages // Delete existing messages
await this.repository.deleteAllMessagesFromConversation(id) await this.repository.deleteAllMessagesFromConversation(id, tx)
// Insert new messages // Insert new messages
for (const message of messages) { for (const message of messages) {
const insertMessage = this.serializeMessage(message, id) const insertMessage = this.serializeMessage(message, id)
await this.repository.createMessage(insertMessage) await this.repository.createMessage(insertMessage, tx)
} }
// Update conversation timestamp // Update conversation timestamp
await this.repository.update(id, { updatedAt: new Date() }) await this.repository.update(id, { updatedAt: new Date() }, tx)
})
} }
async findConversation(id: string): Promise<ChatMessage[] | null> { 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 { App } from 'obsidian'
import { import {
InsertConversation, InsertConversation,
InsertMessage, InsertMessage,
SelectConversation, SelectConversation,
SelectMessage, SelectMessage,
} from '../../schema' } from '../../schema'
export class ConversationRepository { export class ConversationRepository {
private app: App private app: App
private db: PGliteInterface private db: PGliteInterface
constructor(app: App, db: PGliteInterface) { constructor(app: App, db: PGliteInterface) {
this.app = app this.app = app
this.db = db this.db = db
} }
async create(conversation: InsertConversation): Promise<SelectConversation> { async tx(callback: (tx: Transaction) => Promise<void>) {
const result = await this.db.query<SelectConversation>( await this.db.transaction(async (tx) => {
`INSERT INTO conversations (id, title, created_at, updated_at) 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) VALUES ($1, $2, $3, $4)
RETURNING *`, RETURNING *`,
[ [
conversation.id, conversation.id,
conversation.title, conversation.title,
conversation.createdAt || new Date(), conversation.createdAt || new Date(),
conversation.updatedAt || new Date() conversation.updatedAt || new Date()
] ]
) )
return result.rows[0] return result.rows[0]
} }
async createMessage(message: InsertMessage): Promise<SelectMessage> { async createMessage(message: InsertMessage, tx?: Transaction): Promise<SelectMessage> {
const result = await this.db.query<SelectMessage>( const result = await (tx ?? this.db).query<SelectMessage>(
`INSERT INTO messages ( `INSERT INTO messages (
id, conversation_id, apply_status, role, content, reasoning_content, id, conversation_id, apply_status, role, content, reasoning_content,
prompt_content, metadata, mentionables, prompt_content, metadata, mentionables,
similarity_search_results, created_at similarity_search_results, created_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`, RETURNING *`,
[ [
message.id, message.id,
message.conversationId, message.conversationId,
message.apply_status, message.apply_status,
message.role, message.role,
message.content, message.content,
message.reasoningContent, message.reasoningContent,
message.promptContent, message.promptContent,
message.metadata, message.metadata,
message.mentionables, message.mentionables,
message.similaritySearchResults, message.similaritySearchResults,
message.createdAt || new Date() 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> { async findById(id: string, tx?: Transaction): Promise<SelectConversation | undefined> {
const result = await this.db.query<SelectConversation>( const result = await (tx ?? this.db).query<SelectConversation>(
`SELECT * FROM conversations WHERE id = $1 LIMIT 1`, `SELECT * FROM conversations WHERE id = $1 LIMIT 1`,
[id] [id]
) )
return result.rows[0] return result.rows[0]
} }
async findMessagesByConversationId(conversationId: string): Promise<SelectMessage[]> { async findMessagesByConversationId(conversationId: string, tx?: Transaction): Promise<SelectMessage[]> {
const result = await this.db.query<SelectMessage>( const result = await (tx ?? this.db).query<SelectMessage>(
`SELECT * FROM messages `SELECT * FROM messages
WHERE conversation_id = $1 WHERE conversation_id = $1
ORDER BY created_at`, ORDER BY created_at`,
[conversationId] [conversationId]
) )
return result.rows return result.rows
} }
async findAll(): Promise<SelectConversation[]> { async findAll(tx?: Transaction): Promise<SelectConversation[]> {
const result = await this.db.query<SelectConversation>( const result = await (tx ?? this.db).query<SelectConversation>(
`SELECT * FROM conversations ORDER BY created_at DESC` `SELECT * FROM conversations ORDER BY created_at DESC`
) )
return result.rows return result.rows
} }
async update(id: string, data: Partial<InsertConversation>): Promise<SelectConversation> { async update(id: string, data: Partial<InsertConversation>, tx?: Transaction): Promise<SelectConversation> {
const setClauses: string[] = [] const setClauses: string[] = []
const values: (string | Date)[] = [] const values: (string | Date)[] = []
let paramIndex = 1 let paramIndex = 1
if (data.title !== undefined) { if (data.title !== undefined) {
setClauses.push(`title = $${paramIndex}`) setClauses.push(`title = $${paramIndex}`)
values.push(data.title) values.push(data.title)
paramIndex++ paramIndex++
} }
// Always update updated_at // Always update updated_at
setClauses.push(`updated_at = $${paramIndex}`) setClauses.push(`updated_at = $${paramIndex}`)
values.push(new Date()) values.push(new Date())
paramIndex++ paramIndex++
// Add id as the last parameter // Add id as the last parameter
values.push(id) values.push(id)
const result = await this.db.query<SelectConversation>( const result = await (tx ?? this.db).query<SelectConversation>(
`UPDATE conversations `UPDATE conversations
SET ${setClauses.join(', ')} SET ${setClauses.join(', ')}
WHERE id = $${paramIndex} WHERE id = $${paramIndex}
RETURNING *`, RETURNING *`,
values values
) )
return result.rows[0] return result.rows[0]
} }
async delete(id: string): Promise<boolean> { async delete(id: string, tx?: Transaction): Promise<boolean> {
const result = await this.db.query<SelectConversation>( const result = await (tx ?? this.db).query<SelectConversation>(
`DELETE FROM conversations WHERE id = $1 RETURNING *`, `DELETE FROM conversations WHERE id = $1 RETURNING *`,
[id] [id]
) )
return result.rows.length > 0 return result.rows.length > 0
} }
async deleteAllMessagesFromConversation(conversationId: string): Promise<void> { async deleteAllMessagesFromConversation(conversationId: string, tx?: Transaction): Promise<void> {
await this.db.query( const result = await (tx ?? this.db).query(
`DELETE FROM messages WHERE conversation_id = $1`, `DELETE FROM messages WHERE conversation_id = $1`,
[conversationId] [conversationId]
) )
} console.log('deleteAllMessagesFromConversation', conversationId, result)
return
}
} }

View File

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

View File

@ -7,6 +7,7 @@ import { ApplyView } from './ApplyView'
import { ChatView } from './ChatView' import { ChatView } from './ChatView'
import { ChatProps } from './components/chat-view/Chat' import { ChatProps } from './components/chat-view/Chat'
import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants' import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants'
import { getDiffStrategy } from "./core/diff/DiffStrategy"
import { InlineEdit } from './core/edit/inline-edit-processor' import { InlineEdit } from './core/edit/inline-edit-processor'
import { RAGEngine } from './core/rag/rag-engine' import { RAGEngine } from './core/rag/rag-engine'
import { DBManager } from './database/database-manager' import { DBManager } from './database/database-manager'
@ -29,61 +30,77 @@ import {
import { getMentionableBlockData } from './utils/obsidian' import { getMentionableBlockData } from './utils/obsidian'
import './utils/path' import './utils/path'
// Remember to rename these classes and interfaces!
export default class InfioPlugin extends Plugin { 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 settings: InfioSettings
settingTab: InfioSettingTab settingTab: InfioSettingTab
settingsListeners: ((newSettings: InfioSettings) => void)[] = [] settingsListeners: ((newSettings: InfioSettings) => void)[] = []
private activeLeafChangeUnloadFn: (() => void) | null = null
private metadataCacheUnloadFn: (() => void) | null = null
initChatProps?: ChatProps initChatProps?: ChatProps
dbManager: DBManager | null = null dbManager: DBManager | null = null
ragEngine: RAGEngine | null = null ragEngine: RAGEngine | null = null
inlineEdit: InlineEdit | null = null inlineEdit: InlineEdit | null = null
private dbManagerInitPromise: Promise<DBManager> | null = null diffStrategy?: DiffStrategy
private ragEngineInitPromise: Promise<RAGEngine> | null = null
// private pg: PGlite | null = null
async onload() { async onload() {
// load settings
await this.loadSettings() await this.loadSettings()
// Add settings tab // add settings tab
this.settingTab = new InfioSettingTab(this.app, this) this.settingTab = new InfioSettingTab(this.app, this)
this.addSettingTab(this.settingTab) this.addSettingTab(this.settingTab)
// create and init pglite db // add icon to ribbon
// this.pg = await createAndInitDb()
// This creates an icon in the left ribbon.
this.addRibbonIcon('wand-sparkles', 'Open infio copilot', () => this.addRibbonIcon('wand-sparkles', 'Open infio copilot', () =>
this.openChatView(), this.openChatView(),
) )
// register views
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)) this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this))
this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf)) 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.inlineEdit = new InlineEdit(this, this.settings);
this.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => { this.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => {
this.inlineEdit?.Processor(source, el, ctx); this.inlineEdit?.Processor(source, el, ctx);
}); });
// Update inlineEdit when settings change // setup autocomplete event listener
this.addSettingsListener((newSettings) => {
this.inlineEdit = new InlineEdit(this, newSettings);
});
// Setup event listener
const statusBar = StatusBar.fromApp(this); const statusBar = StatusBar.fromApp(this);
const eventListener = EventListener.fromSettings( const eventListener = EventListener.fromSettings(
this.settings, this.settings,
statusBar, statusBar,
this.app 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) => { this.addSettingsListener((newSettings) => {
// Update inlineEdit when settings change
this.inlineEdit = new InlineEdit(this, newSettings);
// Update autocomplete event listener when settings change
eventListener.handleSettingChanged(newSettings) 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([ this.registerEditorExtension([
InlineSuggestionState, InlineSuggestionState,
CompletionKeyWatcher( CompletionKeyWatcher(
@ -107,6 +124,7 @@ export default class InfioPlugin extends Plugin {
} }
}); });
/// *** Event Listeners ***
this.registerEvent( this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => { this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf?.view instanceof MarkdownView) { 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({ this.addCommand({
id: 'open-new-chat', id: 'open-new-chat',
name: 'Open new chat', name: 'Open new chat',
@ -337,7 +355,6 @@ export default class InfioPlugin extends Plugin {
} }
onunload() { onunload() {
// this.dbManager?.cleanup()
this.dbManager = null 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 = { export type SearchWebToolArgs = {
type: 'search_web'; type: 'search_web';
query: string; query: string;
@ -83,4 +90,4 @@ export type SwitchModeToolArgs = {
finish?: boolean; 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), embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google),
embeddingModelId: z.string().catch(''), 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
mode: z.string().catch('ask'), 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' type: 'search_and_replace'
path: string path: string
content: string
operations: { operations: {
search: string search: string
replace: string replace: string
@ -43,6 +44,11 @@ export type ParsedMsgBlock =
regex_flags?: string regex_flags?: string
}[] }[]
finish: boolean finish: boolean
} | {
type: 'apply_diff'
path: string
diff: string
finish: boolean
} | { } | {
type: 'ask_followup_question' type: 'ask_followup_question'
question: string, question: string,
@ -224,7 +230,7 @@ export function parseMsgBlocks(
} }
let path: string | undefined let path: string | undefined
let regex: string | undefined let regex: string | undefined
for (const childNode of node.childNodes) { for (const childNode of node.childNodes) {
if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) { if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) {
path = childNode.childNodes[0].value path = childNode.childNodes[0].value
@ -361,6 +367,7 @@ export function parseMsgBlocks(
} }
let path: string | undefined let path: string | undefined
let operations = [] let operations = []
let content: string = ''
// 处理子标签 // 处理子标签
for (const childNode of node.childNodes) { for (const childNode of node.childNodes) {
@ -368,8 +375,8 @@ export function parseMsgBlocks(
path = childNode.childNodes[0].value path = childNode.childNodes[0].value
} else if (childNode.nodeName === 'operations' && childNode.childNodes.length > 0) { } else if (childNode.nodeName === 'operations' && childNode.childNodes.length > 0) {
try { try {
const operationsJson = childNode.childNodes[0].value content = childNode.childNodes[0].value
operations = JSON5.parse(operationsJson) operations = JSON5.parse(content)
} catch (error) { } catch (error) {
console.error('Failed to parse operations JSON', error) console.error('Failed to parse operations JSON', error)
} }
@ -379,10 +386,41 @@ export function parseMsgBlocks(
parsedResult.push({ parsedResult.push({
type: 'search_and_replace', type: 'search_and_replace',
path, path,
content,
operations, operations,
finish: node.sourceCodeLocation.endTag !== undefined finish: node.sourceCodeLocation.endTag !== undefined
}) })
lastEndOffset = endOffset 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') { } else if (node.nodeName === 'attempt_completion') {
if (!node.sourceCodeLocation) { if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined') throw new Error('sourceCodeLocation is undefined')
@ -443,10 +481,10 @@ export function parseMsgBlocks(
content: input.slice(lastEndOffset, startOffset), content: input.slice(lastEndOffset, startOffset),
}) })
} }
let mode: string = '' let mode: string = ''
let reason: string = '' let reason: string = ''
for (const childNode of node.childNodes) { for (const childNode of node.childNodes) {
if (childNode.nodeName === 'mode_slug' && childNode.childNodes.length > 0) { if (childNode.nodeName === 'mode_slug' && childNode.childNodes.length > 0) {
// @ts-ignore - 忽略 value 属性的类型错误 // @ts-ignore - 忽略 value 属性的类型错误
@ -456,7 +494,7 @@ export function parseMsgBlocks(
reason = childNode.childNodes[0].value reason = childNode.childNodes[0].value
} }
} }
parsedResult.push({ parsedResult.push({
type: 'switch_mode', type: 'switch_mode',
mode, mode,
@ -500,9 +538,9 @@ export function parseMsgBlocks(
content: input.slice(lastEndOffset, startOffset), content: input.slice(lastEndOffset, startOffset),
}) })
} }
let urls: string[] = [] let urls: string[] = []
for (const childNode of node.childNodes) { for (const childNode of node.childNodes) {
if (childNode.nodeName === 'urls' && childNode.childNodes.length > 0) { if (childNode.nodeName === 'urls' && childNode.childNodes.length > 0) {
try { try {
@ -516,7 +554,7 @@ export function parseMsgBlocks(
} }
} }
} }
parsedResult.push({ parsedResult.push({
type: 'fetch_urls_content', type: 'fetch_urls_content',
urls, 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 { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { QueryProgressState } from '../components/chat-view/QueryProgress' import { QueryProgressState } from '../components/chat-view/QueryProgress'
import { DiffStrategy } from '../core/diff/DiffStrategy'
import { SYSTEM_PROMPT } from '../core/prompts/system' import { SYSTEM_PROMPT } from '../core/prompts/system'
import { RAGEngine } from '../core/rag/rag-engine' import { RAGEngine } from '../core/rag/rag-engine'
import { SelectVector } from '../database/schema' import { SelectVector } from '../database/schema'
@ -113,7 +114,7 @@ export class PromptGenerator {
private getRagEngine: () => Promise<RAGEngine> private getRagEngine: () => Promise<RAGEngine>
private app: App private app: App
private settings: InfioSettings private settings: InfioSettings
private diffStrategy: DiffStrategy
private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = { private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = {
role: 'assistant', role: 'assistant',
content: '', content: '',
@ -123,10 +124,12 @@ export class PromptGenerator {
getRagEngine: () => Promise<RAGEngine>, getRagEngine: () => Promise<RAGEngine>,
app: App, app: App,
settings: InfioSettings, settings: InfioSettings,
diffStrategy?: DiffStrategy,
) { ) {
this.getRagEngine = getRagEngine this.getRagEngine = getRagEngine
this.app = app this.app = app
this.settings = settings this.settings = settings
this.diffStrategy = diffStrategy
} }
public async generateRequestMessages({ public async generateRequestMessages({
@ -165,7 +168,7 @@ export class PromptGenerator {
similaritySearchResults, similaritySearchResults,
}, },
] ]
console.log('this.settings.mode', this.settings.mode)
let filesSearchMethod = this.settings.filesSearchMethod let filesSearchMethod = this.settings.filesSearchMethod
if (filesSearchMethod === 'auto' && this.settings.embeddingModelId && this.settings.embeddingModelId !== '') { if (filesSearchMethod === 'auto' && this.settings.embeddingModelId && this.settings.embeddingModelId !== '') {
filesSearchMethod = 'semantic' filesSearchMethod = 'semantic'
@ -173,10 +176,8 @@ export class PromptGenerator {
filesSearchMethod = 'regex' filesSearchMethod = 'regex'
} }
console.log('filesSearchMethod: ', filesSearchMethod)
const userLanguage = getFullLanguageName(getLanguage()) const userLanguage = getFullLanguageName(getLanguage())
console.log(' current user language: ', userLanguage)
const systemMessage = await this.getSystemMessageNew(this.settings.mode, filesSearchMethod, userLanguage) const systemMessage = await this.getSystemMessageNew(this.settings.mode, filesSearchMethod, userLanguage)
const requestMessages: RequestMessage[] = [ const requestMessages: RequestMessage[] = [
@ -466,7 +467,7 @@ export class PromptGenerator {
} }
private async getSystemMessageNew(mode: Mode, filesSearchMethod: string, preferredLanguage: string): Promise<RequestMessage> { 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 { return {
role: 'system', role: 'system',