Squashed 2.9.0 preview 250327

Update preview version

Merge latest from Composer

Support creating new files

Update version to 2.9.0 preview

Add minor fixes

Update autocomplete call and prefix logic

Merge Project mode into 2.9.0-preview

Squashed commits from:

commit a762629dee
Author: Logan Yang <logancyang@gmail.com>
Date:   Tue Mar 25 16:29:31 2025 -0700

    Add projectEnabled flag to CustomModel interface

To

commit 1694357823
Author: wyh <emt934841028@gmail.com>
Date:   Wed Feb 19 14:45:22 2025 +0800

    feat: Support project-based feature.

Fix package vulnerabilities

Prerelease 250325

Update label for PROJECT_CHAIN to "Plus Projects (alpha)" in BasicSettings component
This commit is contained in:
Logan Yang 2025-03-06 22:38:34 -08:00
parent 68bec985df
commit b35290568a
51 changed files with 3863 additions and 496 deletions

View file

@ -1,7 +1,7 @@
{
"id": "copilot",
"name": "Copilot",
"version": "2.8.8",
"version": "2.9.0-preview-250327",
"minAppVersion": "0.15.0",
"description": "An AI Copilot in Obsidian.",
"author": "Logan Yang",
@ -10,4 +10,4 @@
"Buy Me a Coffee": "https://www.buymeacoffee.com/logancyang",
"GitHub Sponsor": "https://github.com/sponsors/logancyang"
}
}
}

705
package-lock.json generated
View file

@ -36,6 +36,7 @@
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.6",
"@tabler/icons-react": "^2.14.0",
"async-mutex": "^0.5.0",
@ -44,8 +45,10 @@
"chrono-node": "^2.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-companion-extension": "^0.0.11",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"diff": "^7.0.0",
"esbuild-plugin-svg": "^0.1.0",
"eventsource-parser": "^1.0.0",
"jotai": "^2.10.3",
@ -71,6 +74,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.1",
"@types/diff": "^7.0.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
@ -1386,89 +1390,19 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"dev": true,
"dependencies": {
"@babel/highlight": "^7.23.4",
"chalk": "^2.4.2"
"@babel/helper-validator-identifier": "^7.25.9",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/code-frame/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/@babel/code-frame/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/code-frame/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/compat-data": {
"version": "7.21.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
@ -1665,18 +1599,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@ -1692,109 +1626,26 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.0.tgz",
"integrity": "sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
"dev": true,
"dependencies": {
"@babel/template": "^7.20.7",
"@babel/traverse": "^7.21.0",
"@babel/types": "^7.21.0"
"@babel/template": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/highlight": {
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/highlight/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/@babel/highlight/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/@babel/highlight/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/highlight/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/highlight/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/parser": {
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"dev": true,
"dependencies": {
"@babel/types": "^7.27.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@ -1980,25 +1831,25 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz",
"integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"dependencies": {
"regenerator-runtime": "^0.13.11"
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.22.13",
"@babel/parser": "^7.22.15",
"@babel/types": "^7.22.15"
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.27.0",
"@babel/types": "^7.27.0"
},
"engines": {
"node": ">=6.9.0"
@ -2035,14 +1886,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.23.4",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@ -2103,22 +1953,83 @@
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.0.3.tgz",
"integrity": "sha512-ZykIcDTVv5UNmKWSTLAs3VukO6NDJkkSKxrgUTDPBkAlORVT3H9n5DbRjRl8xIotklscHdbLIa0b9+y3mQq73g=="
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.0.tgz",
"integrity": "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.10.8",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.8.tgz",
"integrity": "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz",
"integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.10",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz",
"integrity": "sha512-RMdPdmsrUf53pb2VwflKGHEe1XVM07hI7vV2ntgw1dmqhimpatSJKva4VA9h4TLUDOD4EIF02201oZurpnEFsg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.0.tgz",
"integrity": "sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==",
"dev": true,
"peer": true
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.9.5.tgz",
"integrity": "sha512-IZwGdkn8PDyMkGms7bCMpFuTCg6+6eCZNgLOe9xQL4RYvMw1yYLviH+qoAqsNoYGpDt4HhHoW9v3FmalobFoKw==",
"dev": true,
"peer": true,
"version": "6.36.4",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.4.tgz",
"integrity": "sha512-ZQ0V5ovw/miKEXTvjgzRyjnrk9TwriUB1k4R5p7uNnHR9Hus+D1SXHGdJshijEzPFjU25xea/7nhIeSqYFKdbA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.1.4",
"style-mod": "^4.0.0",
"@codemirror/state": "^6.5.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
@ -4632,6 +4543,36 @@
"@langchain/core": ">=0.2.21 <0.4.0"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mistralai/mistralai": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.5.0.tgz",
@ -4644,15 +4585,15 @@
}
},
"node_modules/@next/env": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz",
"integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz",
"integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==",
"peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz",
"integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz",
"integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==",
"cpu": [
"arm64"
],
@ -4666,9 +4607,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz",
"integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz",
"integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==",
"cpu": [
"x64"
],
@ -4682,9 +4623,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz",
"integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz",
"integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==",
"cpu": [
"arm64"
],
@ -4698,9 +4639,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz",
"integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz",
"integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==",
"cpu": [
"arm64"
],
@ -4714,9 +4655,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz",
"integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz",
"integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==",
"cpu": [
"x64"
],
@ -4730,9 +4671,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz",
"integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz",
"integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==",
"cpu": [
"x64"
],
@ -4746,9 +4687,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz",
"integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz",
"integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==",
"cpu": [
"arm64"
],
@ -4762,9 +4703,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz",
"integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz",
"integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==",
"cpu": [
"x64"
],
@ -6534,6 +6475,155 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz",
"integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
"integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz",
@ -8234,6 +8324,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/diff": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz",
"integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
@ -9238,9 +9335,10 @@
}
},
"node_modules/axios": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@ -9972,6 +10070,34 @@
"node": ">= 0.12.0"
}
},
"node_modules/codemirror": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/codemirror-companion-extension": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/codemirror-companion-extension/-/codemirror-companion-extension-0.0.11.tgz",
"integrity": "sha512-m9/aSi0DYCwO//0IhUmdeH/ncJ7LWWYXhepZRT3GAXdsopxI9CYxnB8kbrrRmOaYVfefirzdfTcBF5CegpO9lg==",
"license": "MIT",
"dependencies": {
"codemirror": "^6.0.1"
},
"peerDependencies": {
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.7.2"
}
},
"node_modules/cohere-ai": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/cohere-ai/-/cohere-ai-7.13.0.tgz",
@ -10177,6 +10303,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -10626,6 +10758,15 @@
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -12521,6 +12662,12 @@
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -12731,15 +12878,16 @@
"integrity": "sha512-FTnj+UmNgT3YRml5ruRv0jMZDG7odOL/OP5PF5mOqvXud2vHrPOOs68Zdk6iqzL47cnnM0ZVkK2BAvpFeDJToA=="
},
"node_modules/ibm-cloud-sdk-core": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.1.0.tgz",
"integrity": "sha512-KJCbPz3tiXB1NGAD7cL4JtwpWV8yd/C7jsaHsxvedMo2ZblNG8emMyvSpGhiKAQVZmi3c0ujz6eJdy22NHuUWQ==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ibm-cloud-sdk-core/-/ibm-cloud-sdk-core-5.3.2.tgz",
"integrity": "sha512-YhtS+7hGNO61h/4jNShHxbbuJ1TnDqiFKQzfEaqePnonOvv8NnxWxOk92FlKKCCzZNOT34Gnd7WCLVJTntwEFQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/debug": "^4.1.12",
"@types/node": "~10.14.19",
"@types/node": "^18.19.80",
"@types/tough-cookie": "^4.0.0",
"axios": "1.7.4",
"axios": "^1.8.2",
"camelcase": "^6.3.0",
"debug": "^4.3.4",
"dotenv": "^16.4.5",
@ -12757,10 +12905,14 @@
}
},
"node_modules/ibm-cloud-sdk-core/node_modules/@types/node": {
"version": "10.14.22",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.22.tgz",
"integrity": "sha512-9taxKC944BqoTVjE+UT3pQH0nHZlTvITwfsOZqyc+R3sfJuxaTtxWjfn1K2UlxyPcKHf0rnaXcVFrS9F9vf0bw==",
"peer": true
"version": "18.19.80",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.80.tgz",
"integrity": "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/ibm-cloud-sdk-core/node_modules/camelcase": {
"version": "6.3.0",
@ -16700,12 +16852,12 @@
}
},
"node_modules/next": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz",
"integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==",
"version": "15.2.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz",
"integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==",
"peer": true,
"dependencies": {
"@next/env": "15.1.3",
"@next/env": "15.2.4",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@ -16720,14 +16872,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.1.3",
"@next/swc-darwin-x64": "15.1.3",
"@next/swc-linux-arm64-gnu": "15.1.3",
"@next/swc-linux-arm64-musl": "15.1.3",
"@next/swc-linux-x64-gnu": "15.1.3",
"@next/swc-linux-x64-musl": "15.1.3",
"@next/swc-win32-arm64-msvc": "15.1.3",
"@next/swc-win32-x64-msvc": "15.1.3",
"@next/swc-darwin-arm64": "15.2.4",
"@next/swc-darwin-x64": "15.2.4",
"@next/swc-linux-arm64-gnu": "15.2.4",
"@next/swc-linux-arm64-musl": "15.2.4",
"@next/swc-linux-x64-gnu": "15.2.4",
"@next/swc-linux-x64-musl": "15.2.4",
"@next/swc-win32-arm64-msvc": "15.2.4",
"@next/swc-win32-x64-msvc": "15.2.4",
"sharp": "^0.33.5"
},
"peerDependencies": {
@ -18004,9 +18156,10 @@
"dev": true
},
"node_modules/prismjs": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
"integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@ -18323,12 +18476,14 @@
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.5.0",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz",
"integrity": "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==",
"version": "15.6.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz",
"integrity": "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.27.0",
"refractor": "^3.6.0"
@ -18495,9 +18650,9 @@
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.3",
@ -19618,11 +19773,10 @@
}
},
"node_modules/style-mod": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.3.tgz",
"integrity": "sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==",
"dev": true,
"peer": true
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"license": "MIT"
},
"node_modules/style-to-object": {
"version": "1.0.6",
@ -19919,15 +20073,6 @@
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -20565,9 +20710,7 @@
"node_modules/w3c-keyname": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz",
"integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==",
"dev": true,
"peer": true
"integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg=="
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",

View file

@ -38,6 +38,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.1",
"@types/diff": "^7.0.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
@ -97,6 +98,7 @@
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.6",
"@tabler/icons-react": "^2.14.0",
"async-mutex": "^0.5.0",
@ -105,8 +107,10 @@
"chrono-node": "^2.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror-companion-extension": "^0.0.11",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"diff": "^7.0.0",
"esbuild-plugin-svg": "^0.1.0",
"eventsource-parser": "^1.0.0",
"jotai": "^2.10.3",

View file

@ -71,11 +71,39 @@ export interface Youtube4llmResponse {
elapsed_time_ms: number;
}
interface LicenseResponse {
export interface LicenseResponse {
is_valid: boolean;
plan: string;
}
export interface AutocompleteResponse {
response: {
completion: string;
};
elapsed_time_ms: number;
}
export interface ComposerPromptResponse {
prompt: string;
}
export interface ComposerApplyResponse {
content: string;
}
// Define interface for the composerApply request
export interface ComposerApplyRequest {
target_note: {
title: string;
content: string;
};
chat_history: Array<{
role: string;
content: string;
}>;
markdown_block: string;
}
export class BrevilabsClient {
private static instance: BrevilabsClient;
private pluginVersion: string = "Unknown";
@ -251,4 +279,53 @@ export class BrevilabsClient {
return data;
}
async autocomplete(
prefix: string,
noteContext: string = "",
relevant_notes: string = ""
): Promise<AutocompleteResponse> {
const { data, error } = await this.makeRequest<AutocompleteResponse>("/autocomplete", {
prompt: prefix,
note_context: noteContext,
relevant_notes: relevant_notes,
max_tokens: 64,
});
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from autocomplete");
}
return data;
}
async composerPrompt(): Promise<ComposerPromptResponse> {
const { data, error } = await this.makeRequest<ComposerPromptResponse>(
"/composer/prompt",
{},
"GET"
);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from composerPrompt");
}
return data;
}
async composerApply(request: ComposerApplyRequest): Promise<ComposerApplyResponse> {
const { data, error } = await this.makeRequest<ComposerApplyResponse>(
"/composer/apply",
request
);
if (error) {
throw error;
}
if (!data) {
throw new Error("No data returned from composerApply");
}
return data;
}
}

View file

@ -1,17 +1,17 @@
import {
getChainType,
getCurrentProject,
getModelKey,
SetChainOptions,
setChainType,
subscribeToChainTypeChange,
subscribeToModelKeyChange,
} from "@/aiParams";
import ChainFactory, { ChainType, Document } from "@/chainFactory";
import { BUILTIN_CHAT_MODELS, USER_SENDER, VAULT_VECTOR_STORE_STRATEGY } from "@/constants";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import {
ChainRunner,
CopilotPlusChainRunner,
LLMChainRunner,
ProjectChainRunner,
VaultQAChainRunner,
} from "@/LLMProviders/chainRunner";
import { logError, logInfo } from "@/logger";
@ -32,17 +32,27 @@ import MemoryManager from "./memoryManager";
import PromptManager from "./promptManager";
export default class ChainManager {
private static chain: RunnableSequence;
private static retrievalChain: RunnableSequence;
private chain: RunnableSequence;
private retrievalChain: RunnableSequence;
private retrievedDocuments: Document[] = [];
public getRetrievedDocuments(): Document[] {
return this.retrievedDocuments;
}
public app: App;
public vectorStoreManager: VectorStoreManager;
public chatModelManager: ChatModelManager;
public memoryManager: MemoryManager;
public promptManager: PromptManager;
public static retrievedDocuments: Document[] = [];
// A chat history that stores the messages sent and received
// Only reset when the user explicitly clicks "New Chat"
private chatMessages: ChatMessage[] = [];
constructor(app: App, vectorStoreManager: VectorStoreManager) {
this.chatMessages = [];
// Instantiate singletons
this.app = app;
this.vectorStoreManager = vectorStoreManager;
@ -53,29 +63,21 @@ export default class ChainManager {
// Initialize async operations
this.initialize();
// Set up subscriptions
subscribeToModelKeyChange(async () => await this.createChainWithNewModel());
subscribeToChainTypeChange(() =>
this.setChain(getChainType(), {
refreshIndex:
getSettings().indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
(getChainType() === ChainType.VAULT_QA_CHAIN ||
getChainType() === ChainType.COPILOT_PLUS_CHAIN),
})
);
subscribeToSettingsChange(async () => await this.createChainWithNewModel());
subscribeToSettingsChange(async () => {
await this.createChainWithNewModel();
});
}
private async initialize() {
await this.createChainWithNewModel();
}
static getChain(): RunnableSequence {
return ChainManager.chain;
public getChain(): RunnableSequence {
return this.chain;
}
static getRetrievalChain(): RunnableSequence {
return ChainManager.retrievalChain;
public getRetrievalChain(): RunnableSequence {
return this.retrievalChain;
}
private validateChainType(chainType: ChainType): void {
@ -92,35 +94,60 @@ export default class ChainManager {
}
private validateChainInitialization() {
if (!ChainManager.chain || !isSupportedChain(ChainManager.chain)) {
if (!this.chain || !isSupportedChain(this.chain)) {
console.error("Chain is not initialized properly, re-initializing chain: ", getChainType());
this.setChain(getChainType());
this.createChainWithNewModel({}, false);
// this.setChain(getChainType());
}
}
static storeRetrieverDocuments(documents: Document[]) {
ChainManager.retrievedDocuments = documents;
public storeRetrieverDocuments(documents: Document[]) {
this.retrievedDocuments = documents;
}
/**
* Update the active model and create a new chain with the specified model
* name.
*/
async createChainWithNewModel(): Promise<void> {
let newModelKey = getModelKey();
async createChainWithNewModel(
options: SetChainOptions = {},
neededReInitChatMode: boolean = true
): Promise<void> {
const chainType = getChainType();
const currentProject = getCurrentProject();
if (chainType === ChainType.PROJECT_CHAIN && !currentProject) {
return;
}
let newModelKey =
chainType === ChainType.PROJECT_CHAIN ? currentProject?.projectModelKey : getModelKey();
if (!newModelKey) {
new Notice("No model key found");
throw new Error("No model key found");
}
try {
let customModel = findCustomModel(newModelKey, getSettings().activeModels);
if (!customModel) {
// Reset default model if no model is found
console.error("Resetting default model. No model configuration found for: ", newModelKey);
customModel = BUILTIN_CHAT_MODELS[0];
newModelKey = customModel.name + "|" + customModel.provider;
if (neededReInitChatMode) {
let customModel = findCustomModel(newModelKey, getSettings().activeModels);
if (!customModel) {
// Reset default model if no model is found
console.error("Resetting default model. No model configuration found for: ", newModelKey);
customModel = BUILTIN_CHAT_MODELS[0];
newModelKey = customModel.name + "|" + customModel.provider;
}
const mergedModel = {
...customModel,
...currentProject?.modelConfigs,
};
await this.chatModelManager.setChatModel(mergedModel);
}
await this.chatModelManager.setChatModel(customModel);
// Must update the chatModel for chain because ChainFactory always
// retrieves the old chain without the chatModel change if it exists!
// Create a new chain with the new chatModel
this.setChain(getChainType());
this.setChain(chainType, options);
logInfo(`Setting model to ${newModelKey}`);
} catch (error) {
logError(`createChainWithNewModel failed: ${error}`);
@ -143,7 +170,7 @@ export default class ChainManager {
switch (chainType) {
case ChainType.LLM_CHAIN: {
ChainManager.chain = ChainFactory.createNewLLMChain({
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
@ -164,13 +191,13 @@ export default class ChainManager {
});
// Create new conversational retrieval chain
ChainManager.retrievalChain = ChainFactory.createConversationalRetrievalChain(
this.retrievalChain = ChainFactory.createConversationalRetrievalChain(
{
llm: chatModel,
retriever: retriever,
systemMessage: getSystemPrompt(),
},
ChainManager.storeRetrieverDocuments.bind(ChainManager),
this.storeRetrieverDocuments.bind(this),
getSettings().debug
);
@ -185,7 +212,7 @@ export default class ChainManager {
case ChainType.COPILOT_PLUS_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
ChainManager.chain = ChainFactory.createNewLLMChain({
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
@ -196,6 +223,19 @@ export default class ChainManager {
break;
}
case ChainType.PROJECT_CHAIN: {
// For initial load of the plugin
await this.initializeQAChain(options);
this.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
setChainType(ChainType.PROJECT_CHAIN);
break;
}
default:
this.validateChainType(chainType);
break;
@ -211,6 +251,8 @@ export default class ChainManager {
return new VaultQAChainRunner(this);
case ChainType.COPILOT_PLUS_CHAIN:
return new CopilotPlusChainRunner(this);
case ChainType.PROJECT_CHAIN:
return new ProjectChainRunner(this);
default:
throw new Error(`Unsupported chain type: ${chainType}`);
}
@ -259,9 +301,10 @@ export default class ChainManager {
]);
}
this.setChain(getChainType(), {
this.createChainWithNewModel({ prompt: effectivePrompt }, false);
/*this.setChain(getChainType(), {
prompt: effectivePrompt,
});
});*/
}
const chainRunner = this.getChainRunner();
@ -286,4 +329,20 @@ export default class ChainManager {
}
}
}
public clearHistory() {
this.chatMessages = [];
}
public getChatMessages(): ChatMessage[] {
return this.chatMessages;
}
public setChatMessages(messages: ChatMessage[]) {
this.chatMessages = [...messages];
}
public addChatMessage(message: ChatMessage) {
this.chatMessages.push(message);
}
}

View file

@ -1,4 +1,6 @@
import { getCurrentProject } from "@/aiParams";
import { getStandaloneQuestion } from "@/chainUtils";
import { getComposerSystemPrompt } from "@/composerUtils";
import {
ABORT_REASON,
AI_SENDER,
@ -9,7 +11,6 @@ import {
} from "@/constants";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { logError } from "@/logger";
import { getSystemPrompt } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { ToolManager } from "@/tools/toolManager";
import {
@ -28,6 +29,7 @@ import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { Notice } from "obsidian";
import ChainManager from "./chainManager";
import { COPILOT_TOOL_NAMES, IntentAnalyzer } from "./intentAnalyzer";
import ProjectManager from "./projectManager";
class ThinkBlockStreamer {
private hasOpenThinkBlock = false;
@ -80,7 +82,15 @@ export interface ChainRunner {
}
abstract class BaseChainRunner implements ChainRunner {
constructor(protected chainManager: ChainManager) {}
protected chainManager: ChainManager;
constructor(chainManager: ChainManager) {
this.chainManager = chainManager;
}
protected async getSystemPrompt(): Promise<string> {
return getComposerSystemPrompt();
}
abstract run(
userMessage: ChatMessage,
@ -201,7 +211,7 @@ class LLMChainRunner extends BaseChainRunner {
const streamer = new ThinkBlockStreamer(updateCurrentAiMessage);
try {
const chain = ChainManager.getChain();
const chain = this.chainManager.getChain();
const chatStream = await chain.stream({
input: userMessage.message,
} as any);
@ -257,7 +267,7 @@ class VaultQAChainRunner extends BaseChainRunner {
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
const qaStream = await ChainManager.getRetrievalChain().stream({
const qaStream = await this.chainManager.getRetrievalChain().stream({
question: userMessage.message,
chat_history: chatHistory,
} as any);
@ -284,7 +294,7 @@ class VaultQAChainRunner extends BaseChainRunner {
}
private addSourcestoResponse(response: string): string {
const docTitles = extractUniqueTitlesFromDocs(ChainManager.retrievedDocuments);
const docTitles = extractUniqueTitlesFromDocs(this.chainManager.getRetrievedDocuments());
if (docTitles.length > 0) {
const links = docTitles.map((title) => `- [[${title}]]`).join("\n");
response += "\n\n#### Sources:\n\n" + links;
@ -427,7 +437,8 @@ class CopilotPlusChainRunner extends BaseChainRunner {
const messages: any[] = [];
// Add system message if available
let fullSystemMessage = getSystemPrompt();
let fullSystemMessage = await this.getSystemPrompt();
// Add chat history context to system message if exists
if (chatHistory.length > 0) {
@ -756,4 +767,25 @@ class CopilotPlusChainRunner extends BaseChainRunner {
}
}
export { CopilotPlusChainRunner, LLMChainRunner, VaultQAChainRunner };
class ProjectChainRunner extends CopilotPlusChainRunner {
protected async getSystemPrompt(): Promise<string> {
// get current project
const projectConfig = getCurrentProject();
if (!projectConfig) {
return super.getSystemPrompt();
}
// Get cached context synchronously
const context = ProjectManager.instance.getProjectContext(projectConfig.id);
let finalPrompt = projectConfig.systemPrompt;
if (context) {
finalPrompt = `${finalPrompt}\n\n${context}`;
}
return finalPrompt;
}
}
export { CopilotPlusChainRunner, LLMChainRunner, ProjectChainRunner, VaultQAChainRunner };

View file

@ -1,4 +1,4 @@
import { CustomModel, getModelKey, ModelConfig, setModelKey } from "@/aiParams";
import { CustomModel, getModelKey, ModelConfig } from "@/aiParams";
import {
BREVILABS_API_BASE_URL,
BUILTIN_CHAT_MODELS,
@ -306,7 +306,8 @@ export default class ChatModelManager {
async setChatModel(model: CustomModel): Promise<void> {
const modelKey = getModelKeyFromModel(model);
setModelKey(modelKey);
// todo 为啥这里需要重新设置?
// setModelKey(modelKey);
try {
const modelInstance = await this.createModelInstance(model);
ChatModelManager.chatModel = modelInstance;

View file

@ -11,8 +11,8 @@ import { simpleYoutubeTranscriptionTool } from "@/tools/YoutubeTools";
import { ToolManager } from "@/tools/toolManager";
import { extractChatHistory, extractYoutubeUrl } from "@/utils";
import { BrevilabsClient } from "./brevilabsClient";
import MemoryManager from "./memoryManager";
import { Vault } from "obsidian";
import ProjectManager from "@/LLMProviders/projectManager";
// TODO: Add @index with explicit pdf files in chat context menu
export const COPILOT_TOOL_NAMES = ["@vault", "@web", "@youtube", "@pomodoro"];
@ -112,7 +112,7 @@ export class IntentAnalyzer {
// Handle @web command
if (message.includes("@web")) {
const cleanQuery = this.removeAtCommands(originalMessage);
const memory = MemoryManager.getInstance().getMemory();
const memory = ProjectManager.instance.getCurrentChainManager().memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);

View file

@ -0,0 +1,339 @@
import {
getChainType,
isProjectMode,
ProjectConfig,
setProjectLoading,
subscribeToChainTypeChange,
subscribeToModelKeyChange,
subscribeToProjectChange,
} from "@/aiParams";
import { ProjectContextCache } from "@/cache/projectContextCache";
import { ChainType } from "@/chainFactory";
import { updateChatMemory } from "@/chatUtils";
import CopilotView from "@/components/CopilotView";
import { CHAT_VIEWTYPE, VAULT_VECTOR_STORE_STRATEGY } from "@/constants";
import { logError, logInfo } from "@/logger";
import CopilotPlugin from "@/main";
import { Mention } from "@/mentions/Mention";
import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { err2String } from "@/utils";
import { App, Notice } from "obsidian";
import VectorStoreManager from "../search/vectorStoreManager";
import { BrevilabsClient } from "./brevilabsClient";
import ChainManager from "./chainManager";
export default class ProjectManager {
public static instance: ProjectManager;
private currentProjectId: string | null;
private app: App;
private plugin: CopilotPlugin;
private readonly chainMangerInstance: ChainManager;
private readonly projectContextCache: ProjectContextCache;
private chatMessageCache: Map<string, ChatMessage[]>;
private defaultProjectKey: string = "defaultProjectKey";
private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) {
this.app = app;
this.plugin = plugin;
this.currentProjectId = null;
this.chainMangerInstance = new ChainManager(app, vectorStoreManager);
this.projectContextCache = ProjectContextCache.getInstance();
this.chatMessageCache = new Map();
// Set up subscriptions
subscribeToModelKeyChange(async () => {
await this.getCurrentChainManager().createChainWithNewModel();
});
subscribeToChainTypeChange(async () => {
// When switching from other modes to project mode, no need to update the chain.
if (isProjectMode()) {
return;
}
await this.getCurrentChainManager().createChainWithNewModel({
refreshIndex:
getSettings().indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH &&
(getChainType() === ChainType.VAULT_QA_CHAIN ||
getChainType() === ChainType.COPILOT_PLUS_CHAIN),
});
});
// Subscribe to Project changes
subscribeToProjectChange(async (project) => {
await this.switchProject(project);
});
// Subscribe to settings changes to monitor projectList changes
this.setupProjectListChangeMonitor();
}
private setupProjectListChangeMonitor() {
subscribeToSettingsChange(async (prev, next) => {
if (!prev || !next) return;
const prevProjects = prev.projectList || [];
const nextProjects = next.projectList || [];
// Find modified projects
for (const nextProject of nextProjects) {
const prevProject = prevProjects.find((p) => p.id === nextProject.id);
if (prevProject) {
// Check if project configuration has changed
if (JSON.stringify(prevProject) !== JSON.stringify(nextProject)) {
// Clear context cache for this project
await this.projectContextCache.clearForProject(nextProject);
// If this is the current project, reload its context and recreate chain
if (this.currentProjectId === nextProject.id) {
await Promise.all([
this.loadProjectContext(nextProject),
// Recreate chain to pick up new system prompt
this.getCurrentChainManager().createChainWithNewModel(),
]);
}
}
}
}
});
}
public static getInstance(
app: App,
vectorStoreManager: VectorStoreManager,
plugin: CopilotPlugin
): ProjectManager {
if (!ProjectManager.instance) {
ProjectManager.instance = new ProjectManager(app, vectorStoreManager, plugin);
}
return ProjectManager.instance;
}
public getCurrentChainManager(): ChainManager {
return this.chainMangerInstance;
}
public async switchProject(project: ProjectConfig | null): Promise<void> {
try {
setProjectLoading(true);
// 1. save current project message. 2. load next project message
// switch default project
if (!project) {
await this.saveCurrentProjectMessage();
this.currentProjectId = null; // ensure set currentProjectId
await this.loadNextProjectMessage();
this.refreshChatView();
return;
}
// else
const projectId = project.id;
if (this.currentProjectId === projectId) {
return;
}
await this.saveCurrentProjectMessage();
this.currentProjectId = projectId; // ensure set currentProjectId
await Promise.all([
// load memory
this.loadNextProjectMessage(),
// update chat model
this.getCurrentChainManager().createChainWithNewModel(),
// load context
this.loadProjectContext(project),
]);
// fresh chat view
this.refreshChatView();
logInfo(`Switched to project: ${project.name}`);
} catch (error) {
logError(`Failed to switch project: ${error}`);
throw error;
} finally {
setProjectLoading(false);
}
}
private async saveCurrentProjectMessage() {
// save show message
this.chatMessageCache.set(
this.currentProjectId ? this.currentProjectId : this.defaultProjectKey,
this.getCurrentChainManager().getChatMessages()
);
// TODO(emt-lin): do this or not?
await this.plugin.autosaveCurrentChat();
}
private async loadNextProjectMessage() {
const chainManager = this.getCurrentChainManager();
const messages =
this.chatMessageCache.get(
this.currentProjectId ? this.currentProjectId : this.defaultProjectKey
) ?? [];
chainManager.setChatMessages(messages);
await updateChatMemory(messages, chainManager.memoryManager);
}
// TODO(logan): This should be reused as a generic context loading function
private async loadProjectContext(project: ProjectConfig): Promise<void> {
try {
if (project.contextSource) {
// Try to get context from cache first
const cachedContext = await this.projectContextCache.get(project);
if (cachedContext) {
return;
}
const [markdownContext, webContext, youtubeContext] = await Promise.all([
this.processMarkdownContext(
project.contextSource.inclusions,
project.contextSource.exclusions
),
this.processWebUrlsContext(project.contextSource.webUrls),
this.processYoutubeUrlsContext(project.contextSource.youtubeUrls),
]);
// Build context sections only for non-null sources
const contextParts = [];
if (project.contextSource.inclusions || project.contextSource.exclusions) {
contextParts.push(`## Markdown Files\n${markdownContext}`);
}
if (project.contextSource.webUrls?.trim()) {
contextParts.push(`## Web Content\n${webContext}`);
}
if (project.contextSource.youtubeUrls?.trim()) {
contextParts.push(`## YouTube Content\n${youtubeContext}`);
}
const contextText = `
# Project Context
The following information is the relevant context for this project. Use this information to inform your responses when appropriate:
<ProjectContext>
${contextParts.join("\n\n")}
</ProjectContext>
`;
// Cache the generated context
await this.projectContextCache.set(project, contextText);
}
} catch (error) {
logError(`Failed to load project context: ${error}`);
throw error;
}
}
private refreshChatView() {
// get chat view
const chatView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0]?.view as CopilotView;
if (chatView) {
chatView.updateView();
}
}
public getProjectContext(projectId: string): string | null {
const project = getSettings().projectList.find((p) => p.id === projectId);
if (!project) {
return null;
}
return this.projectContextCache.getSync(project);
}
public async clearContextCache(projectId: string): Promise<void> {
const project = getSettings().projectList.find((p) => p.id === projectId);
if (project) {
await this.projectContextCache.clearForProject(project);
logInfo(`Context cache cleared for project: ${projectId}`);
}
}
private async processMarkdownContext(inclusions?: string, exclusions?: string): Promise<string> {
if (!inclusions && !exclusions) {
return "";
}
// NOTE: Must not fallback to GLOBAL inclusions and exclusions in Copilot settings in Projects!
// This is to avoid project inclusions in the project that conflict with the global ones
// Project UI should be the ONLY source of truth for project inclusions and exclusions
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({
inclusions,
exclusions,
isProject: true,
});
const files = this.app.vault.getMarkdownFiles().filter((file) => {
return shouldIndexFile(file, inclusionPatterns, exclusionPatterns);
});
// Process each file with its metadata
const processedNotes = await Promise.all(
files.map(async (file) => {
const content = await this.app.vault.cachedRead(file);
const stat = await this.app.vault.adapter.stat(file.path);
// Format the note with metadata
return `[[${file.basename}]]
path: ${file.path}
created: ${stat ? new Date(stat.ctime).toISOString() : "unknown"}
modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}
${content}`;
})
);
// Join all processed notes with double newlines
return processedNotes.join("\n\n");
}
private async processWebUrlsContext(webUrls?: string): Promise<string> {
if (!webUrls?.trim()) {
return "";
}
try {
const mention = Mention.getInstance();
const { urlContext } = await mention.processUrls(webUrls);
return urlContext || "";
} catch (error) {
logError(`Failed to process web URLs: ${error}`);
new Notice(`Failed to process web URLs: ${err2String(error)}`);
return "";
}
}
private async processYoutubeUrlsContext(youtubeUrls?: string): Promise<string> {
if (!youtubeUrls?.trim()) {
return "";
}
const urls = youtubeUrls.split("\n").filter((url) => url.trim());
const processPromises = urls.map(async (url) => {
try {
const response = await BrevilabsClient.getInstance().youtube4llm(url);
if (response.response.transcript) {
return `\n\nYouTube transcript from ${url}:\n${response.response.transcript}`;
}
return "";
} catch (error) {
logError(`Failed to process YouTube URL ${url}: ${error}`);
new Notice(`Failed to process YouTube URL ${url}: ${err2String(error)}`);
return "";
}
});
const results = await Promise.all(processPromises);
return results.join("");
}
}

View file

@ -5,6 +5,7 @@ import {
MessagesPlaceholder,
SystemMessagePromptTemplate,
} from "@langchain/core/prompts";
import { getCurrentProject, isProjectMode, subscribeToProjectChange } from "@/aiParams";
export default class PromptManager {
private static instance: PromptManager;
@ -19,6 +20,11 @@ export default class PromptManager {
this.initChatPrompt();
this.initQAPrompt();
});
subscribeToProjectChange(() => {
this.initChatPrompt();
this.initQAPrompt();
});
}
static getInstance(): PromptManager {
@ -29,8 +35,15 @@ export default class PromptManager {
}
private initChatPrompt(): void {
let systemPrompt = getSystemPrompt();
const currentProject = getCurrentProject();
if (currentProject && isProjectMode()) {
systemPrompt = currentProject.systemPrompt;
}
// Escape curly braces in the system message
const escapedSystemMessage = this.escapeTemplateString(getSystemPrompt());
const escapedSystemMessage = this.escapeTemplateString(systemPrompt);
this.chatPrompt = ChatPromptTemplate.fromMessages([
SystemMessagePromptTemplate.fromTemplate(escapedSystemMessage),

View file

@ -34,6 +34,29 @@ const chainTypeAtom = atom(
}
);
const currentProjectAtom = atom<ProjectConfig | null>(null);
const projectLoadingAtom = atom<boolean>(false);
export interface ProjectConfig {
id: string;
name: string;
description?: string;
systemPrompt: string;
projectModelKey: string;
modelConfigs: {
temperature?: number;
maxTokens?: number;
};
contextSource: {
inclusions: string;
exclusions?: string;
webUrls?: string;
youtubeUrls?: string;
};
created: number;
UsageTimestamps: number;
}
export interface ModelConfig {
modelName: string;
temperature: number;
@ -79,7 +102,9 @@ export interface CustomModel {
stream?: boolean;
temperature?: number;
maxTokens?: number;
context?: number;
projectEnabled?: boolean;
plusExclusive?: boolean;
believerExclusive?: boolean;
capabilities?: ModelCapability[];
@ -132,3 +157,49 @@ export function useChainType() {
store: settingsStore,
});
}
export function setCurrentProject(project: ProjectConfig | null) {
settingsStore.set(currentProjectAtom, project);
}
export function getCurrentProject(): ProjectConfig | null {
return settingsStore.get(currentProjectAtom);
}
export function subscribeToProjectChange(
callback: (project: ProjectConfig | null) => void
): () => void {
return settingsStore.sub(currentProjectAtom, () => {
callback(settingsStore.get(currentProjectAtom));
});
}
export function useCurrentProject() {
return useAtom(currentProjectAtom, {
store: settingsStore,
});
}
export function setProjectLoading(loading: boolean) {
settingsStore.set(projectLoadingAtom, loading);
}
export function isProjectLoading(): boolean {
return settingsStore.get(projectLoadingAtom);
}
export function subscribeToProjectLoadingChange(callback: (loading: boolean) => void): () => void {
return settingsStore.sub(projectLoadingAtom, () => {
callback(settingsStore.get(projectLoadingAtom));
});
}
export function useProjectLoading() {
return useAtom(projectLoadingAtom, {
store: settingsStore,
});
}
export function isProjectMode() {
return getChainType() === ChainType.PROJECT_CHAIN;
}

View file

@ -0,0 +1,58 @@
import { AUTOCOMPLETE_CONFIG } from "@/constants";
import { logError, logInfo } from "@/logger";
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
import { Plugin } from "obsidian";
import { CodeMirrorIntegration } from "./codemirrorIntegration";
export class AutocompleteService {
private static instance: AutocompleteService;
private cmIntegration: CodeMirrorIntegration;
private unsubscribeSettings: () => void;
private constructor(private plugin: Plugin) {
// Initialize with current settings
const settings = getSettings();
this.cmIntegration = CodeMirrorIntegration.getInstance({
delay: AUTOCOMPLETE_CONFIG.DELAY_MS,
minTriggerLength: AUTOCOMPLETE_CONFIG.MIN_TRIGGER_LENGTH,
maxContextLength: AUTOCOMPLETE_CONFIG.MAX_CONTEXT_LENGTH,
});
// Subscribe to settings changes
this.unsubscribeSettings = subscribeToSettingsChange((prev, next) => {
// Only need to handle enableAutocomplete changes
if (prev.enableAutocomplete !== next.enableAutocomplete) {
logInfo(`Settings changed - enableAutocomplete: ${next.enableAutocomplete}`);
this.cmIntegration.setActive(next.enableAutocomplete);
}
});
// Set initial active state
this.cmIntegration.setActive(settings.enableAutocomplete);
// Register the extension globally
this.registerExtension();
}
static getInstance(plugin: Plugin): AutocompleteService {
if (!AutocompleteService.instance) {
AutocompleteService.instance = new AutocompleteService(plugin);
}
return AutocompleteService.instance;
}
private registerExtension() {
try {
this.plugin.registerEditorExtension([this.cmIntegration.getExtension()]);
} catch (error) {
logError("Failed to register CodeMirror extension:", error);
}
}
destroy() {
logInfo("Destroying AutocompleteService");
this.unsubscribeSettings();
this.cmIntegration.setActive(false);
}
}

View file

@ -0,0 +1,154 @@
import { AutocompleteCache } from "@/cache/autocompleteCache";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { logError, logInfo } from "@/logger";
import { Extension } from "@codemirror/state";
import { forceableInlineSuggestion, type Suggestion } from "codemirror-companion-extension";
import { MarkdownView } from "obsidian";
import { getEditorContext, isNonSpaceDelimitedText, RelevantNotesCache } from "./utils";
export interface AutocompleteOptions {
delay: number;
minTriggerLength: number;
maxContextLength: number;
}
export class CodeMirrorIntegration {
private static instance: CodeMirrorIntegration;
private cache: AutocompleteCache;
private client: BrevilabsClient;
private extension: Extension;
private forceFetch: () => void;
private isActive = false;
private lastSuggestionTime: number = 0;
private readonly MIN_SUGGESTION_INTERVAL = 200; // ms
private constructor(private options: AutocompleteOptions) {
this.cache = AutocompleteCache.getInstance();
this.client = BrevilabsClient.getInstance();
const { extension, force_fetch } = forceableInlineSuggestion({
fetchFn: () => this.handleCompletion(),
delay: this.options.delay,
continue_suggesting: false,
accept_shortcut: "Tab",
});
this.extension = extension;
this.forceFetch = force_fetch;
}
static getInstance(options: AutocompleteOptions): CodeMirrorIntegration {
if (!CodeMirrorIntegration.instance) {
CodeMirrorIntegration.instance = new CodeMirrorIntegration(options);
}
return CodeMirrorIntegration.instance;
}
setActive(active: boolean) {
this.isActive = active;
}
getExtension(): Extension {
return this.extension;
}
private canGenerateNewSuggestion(): boolean {
const now = Date.now();
if (now - this.lastSuggestionTime < this.MIN_SUGGESTION_INTERVAL) {
return false;
}
this.lastSuggestionTime = now;
return true;
}
private shouldContinueSuggesting(context: string): boolean {
// Get the last word, ignoring emojis and special characters
const words = context
.trim()
.split(/\s+/)
.filter((word) => word.replace(/[\p{Emoji}\p{Symbol}\p{Punctuation}]/gu, "").length > 0);
const lastWord = words[words.length - 1] || "";
// If contains CJK characters, always trigger
if (isNonSpaceDelimitedText(lastWord)) {
return true;
}
// For space-delimited languages (e.g., English), trigger on space or newline
return context.endsWith(" ") || context.endsWith("\n");
}
private async *handleCompletion(): AsyncGenerator<Suggestion> {
if (!this.isActive) {
logInfo("[Copilot Autocomplete] Autocomplete is not active");
return;
}
if (!this.canGenerateNewSuggestion()) {
logInfo("[Copilot Autocomplete] Rate limit - skipping suggestion");
return;
}
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
logInfo("[Copilot Autocomplete] No active MarkdownView found");
return;
}
const editor = view.editor;
const cursor = editor.getCursor();
const { prefix, noteContext } = getEditorContext(editor, cursor);
// Check if we should continue suggesting based on context
if (!this.shouldContinueSuggesting(prefix)) {
logInfo("[Copilot Autocomplete] Context indicates no further suggestions needed");
return;
}
// Trim context if too long
const trimmedPrefix = prefix.slice(-this.options.maxContextLength);
if (trimmedPrefix.length < this.options.minTriggerLength) {
logInfo(
`[Copilot Autocomplete] Prefix length ${trimmedPrefix.length} is below minimum trigger length ${this.options.minTriggerLength}`
);
return;
}
try {
// Get relevant notes from cache
const relevantNotesStr = await RelevantNotesCache.getInstance().getRelevantNotes(view.file);
const relevantNoteTitles = RelevantNotesCache.getInstance().getRelevantNoteTitles();
logInfo("[Copilot Autocomplete] Relevant notes:", relevantNoteTitles);
// Prepend current note title to the prefix
const currentNoteTitle = view.file?.basename || "";
const prefixWithTitle = currentNoteTitle
? `[[${currentNoteTitle}]]:\n\n${trimmedPrefix}`
: trimmedPrefix;
// Get completion from API
const response = await this.client.autocomplete(
prefixWithTitle,
noteContext,
relevantNotesStr
);
const completion = response.response.completion;
yield {
display_suggestion: completion,
complete_suggestion: completion,
};
} catch (error) {
logError("[Copilot Autocomplete] Error fetching autocomplete suggestions:", error);
}
}
triggerCompletion() {
if (!this.isActive) {
return;
}
this.forceFetch();
}
}

195
src/autocomplete/utils.ts Normal file
View file

@ -0,0 +1,195 @@
import { findRelevantNotes } from "@/search/findRelevantNotes";
import VectorStoreManager from "@/search/vectorStoreManager";
import { Editor, TFile } from "obsidian";
/**
* Unicode ranges for different writing systems
* Reference: https://unicode.org/charts/
*/
const WRITING_SYSTEMS = {
// CJK (Chinese, Japanese Kanji, Korean Hanja)
cjk: [
[0x4e00, 0x9fff], // CJK Unified
[0x3400, 0x4dbf], // CJK Extension A
[0x20000, 0x2a6df], // CJK Extension B
[0x2a700, 0x2b73f], // CJK Extension C
[0x2b740, 0x2b81f], // CJK Extension D
[0x2b820, 0x2ceaf], // CJK Extension E
],
// Japanese-specific
japanese: [
[0x3040, 0x309f], // Hiragana
[0x30a0, 0x30ff], // Katakana
],
// Korean-specific
korean: [
[0xac00, 0xd7af], // Hangul Syllables
[0x1100, 0x11ff], // Hangul Jamo
],
// Other non-space-delimited scripts
other: [
[0x0e00, 0x0e7f], // Thai
[0x0600, 0x06ff], // Arabic
[0x0900, 0x097f], // Devanagari (Hindi)
[0x0980, 0x09ff], // Bengali
[0x0a80, 0x0aff], // Gujarati
[0x0b80, 0x0bff], // Tamil
],
} satisfies Record<string, Array<[number, number]>>;
/**
* Tests if a character is within any of the given Unicode ranges
*/
function isInRanges(char: string, ranges: readonly number[][]): boolean {
const code = char.codePointAt(0);
if (!code) return false;
return ranges.some(([start, end]) => code >= start && code <= end);
}
/**
* Determines if the text contains any non-space-delimited writing systems
* This includes CJK characters, Japanese kana, Korean hangul, and other scripts
* that don't use spaces between words
*/
export function isNonSpaceDelimitedText(text: string): boolean {
// Remove emojis and special characters before testing
const cleanText = text.replace(/[\p{Emoji}\p{Symbol}\p{Punctuation}]/gu, "");
if (!cleanText) return false;
// Test each character against all writing systems
for (const char of cleanText) {
if (Object.values(WRITING_SYSTEMS).some((ranges) => isInRanges(char, ranges))) {
return true;
}
}
return false;
}
/**
* Gets the context around the cursor
* @param editor The editor instance
* @param cursor The cursor position
* @returns Object containing prefix (a few lines before cursor) and noteContext (50 lines before/after with cursor marker)
*/
export function getEditorContext(
editor: Editor,
cursor: { line: number; ch: number }
): { prefix: string; noteContext: string } {
let prefix = "";
let noteContext = "";
// Get last 5 lines for prefix
const prefixStartLine = Math.max(0, cursor.line - 5);
for (let i = prefixStartLine; i < cursor.line; i++) {
prefix += editor.getLine(i) + "\n";
}
// Add content from cursor line up to cursor position
const cursorLine = editor.getLine(cursor.line);
prefix += cursorLine.slice(0, cursor.ch);
// If cursor is at the end of an empty line or after a newline, add a newline to prefix
if (cursor.ch === 0 || (cursor.ch === cursorLine.length && cursorLine.trim() === "")) {
prefix += "\n";
}
// Get 50 lines before cursor for noteContext
const contextStartLine = Math.max(0, cursor.line - 50);
for (let i = contextStartLine; i < cursor.line; i++) {
noteContext += editor.getLine(i) + "\n";
}
// Add cursor line with {{CURSOR}} marker
noteContext += cursorLine.slice(0, cursor.ch) + "{{CURSOR}}" + cursorLine.slice(cursor.ch) + "\n";
// Add up to 50 lines after cursor
const contextEndLine = Math.min(editor.lineCount() - 1, cursor.line + 50);
for (let i = cursor.line + 1; i <= contextEndLine; i++) {
noteContext += editor.getLine(i) + "\n";
}
return { prefix, noteContext };
}
export class RelevantNotesCache {
private static instance: RelevantNotesCache;
private currentNotePath: string | null = null;
private cachedNotes: string | null = null;
private cachedNoteTitles: string[] = [];
private static readonly MAX_RELEVANT_NOTES = 1;
private constructor() {}
static getInstance(): RelevantNotesCache {
if (!RelevantNotesCache.instance) {
RelevantNotesCache.instance = new RelevantNotesCache();
}
return RelevantNotesCache.instance;
}
/**
* Gets the titles of currently cached relevant notes
*/
getRelevantNoteTitles(): string[] {
return this.cachedNoteTitles;
}
/**
* Gets the formatted relevant notes string for the current note.
* If the notes are already cached and the note hasn't changed, returns the cached version.
* Otherwise, fetches and caches new relevant notes.
*/
async getRelevantNotes(file: TFile | null): Promise<string> {
// If no file is active, return empty string
if (!file) {
this.currentNotePath = null;
this.cachedNotes = null;
this.cachedNoteTitles = [];
return "";
}
// If we already have cached notes for this file, return them
if (this.currentNotePath === file.path && this.cachedNotes !== null) {
return this.cachedNotes;
}
// Otherwise, fetch and cache new relevant notes
const db = await VectorStoreManager.getInstance().getDb();
const relevantNotes = await findRelevantNotes({ db, filePath: file.path });
// Get top N relevant notes
const topNotes = relevantNotes.slice(0, RelevantNotesCache.MAX_RELEVANT_NOTES);
// Update cached titles
this.cachedNoteTitles = topNotes.map((note) => note.document.title);
// Format notes as required
const formattedNotes = await Promise.all(
topNotes.map(async (note) => {
const noteFile = app.vault.getAbstractFileByPath(note.document.path);
if (!(noteFile instanceof TFile)) return "";
const content = await app.vault.cachedRead(noteFile);
if (!content) return "";
// Truncate content if over 3000 chars
const truncatedContent = content.length > 3000 ? content.slice(0, 3000) + "..." : content;
return `[[${note.document.title}]]:\n\n${truncatedContent}`;
})
);
// Update cache
this.currentNotePath = file.path;
this.cachedNotes = formattedNotes.join("\n\n");
return this.cachedNotes;
}
/**
* Clears the cache, forcing a refresh on the next getRelevantNotes call
*/
clearCache() {
this.currentNotePath = null;
this.cachedNotes = null;
this.cachedNoteTitles = [];
}
}

64
src/cache/autocompleteCache.ts vendored Normal file
View file

@ -0,0 +1,64 @@
import { logInfo } from "@/logger";
interface CacheEntry {
completion: string;
timestamp: number;
}
export class AutocompleteCache {
private static instance: AutocompleteCache;
private cache: Map<string, CacheEntry>;
private readonly maxSize: number;
private readonly ttlMs: number;
private constructor() {
this.cache = new Map();
this.maxSize = 100; // Cache up to 100 completions
this.ttlMs = 5 * 60 * 1000; // 5 minutes TTL
}
static getInstance(): AutocompleteCache {
if (!AutocompleteCache.instance) {
AutocompleteCache.instance = new AutocompleteCache();
}
return AutocompleteCache.instance;
}
get(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// Check if entry has expired
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return undefined;
}
return entry.completion;
}
set(key: string, completion: string): void {
// If cache is full, remove oldest entry
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, {
completion,
timestamp: Date.now(),
});
logInfo("Cached autocomplete suggestion for key:", key);
}
clear(): void {
this.cache.clear();
logInfo("Cleared autocomplete cache");
}
generateKey(prefix: string): string {
// Use last N characters as key to keep context relevant
const keyLength = 100;
return prefix.length <= keyLength ? prefix : prefix.slice(prefix.length - keyLength);
}
}

140
src/cache/projectContextCache.ts vendored Normal file
View file

@ -0,0 +1,140 @@
import { ProjectConfig } from "@/aiParams";
import { logError, logInfo } from "@/logger";
import { MD5 } from "crypto-js";
export class ProjectContextCache {
private static instance: ProjectContextCache;
private cacheDir: string = ".copilot/project-context-cache";
private memoryCache: Map<string, string> = new Map();
private constructor() {}
static getInstance(): ProjectContextCache {
if (!ProjectContextCache.instance) {
ProjectContextCache.instance = new ProjectContextCache();
}
return ProjectContextCache.instance;
}
private async ensureCacheDir() {
if (!(await app.vault.adapter.exists(this.cacheDir))) {
logInfo("Creating project context cache directory:", this.cacheDir);
await app.vault.adapter.mkdir(this.cacheDir);
}
}
private getCacheKey(project: ProjectConfig): string {
// Use project ID, system prompt, and context sources for a unique cache key
logInfo("Generating cache key for project context:", project.contextSource);
const metadata = JSON.stringify({
id: project.id,
contextSource: project.contextSource,
systemPrompt: project.systemPrompt,
});
const key = MD5(metadata).toString();
logInfo("Generated cache key for project:", { name: project.name, key });
return key;
}
private getCachePath(cacheKey: string): string {
return `${this.cacheDir}/${cacheKey}.json`;
}
async get(project: ProjectConfig): Promise<string | null> {
try {
const cacheKey = this.getCacheKey(project);
// Check memory cache first
const memoryResult = this.memoryCache.get(cacheKey);
if (memoryResult) {
logInfo("Memory cache hit for project:", project.name);
return memoryResult;
}
const cachePath = this.getCachePath(cacheKey);
if (await app.vault.adapter.exists(cachePath)) {
logInfo("File cache hit for project:", project.name);
const cacheContent = await app.vault.adapter.read(cachePath);
const context = JSON.parse(cacheContent).context;
// Store in memory cache
this.memoryCache.set(cacheKey, context);
return context;
}
logInfo("Cache miss for project:", project.name);
return null;
} catch (error) {
logError("Error reading from project context cache:", error);
return null;
}
}
getSync(project: ProjectConfig): string | null {
try {
const cacheKey = this.getCacheKey(project);
const memoryResult = this.memoryCache.get(cacheKey);
if (memoryResult) {
logInfo("Memory cache hit for project:", project.name);
return memoryResult;
}
logInfo("Memory cache miss for project:", project.name);
return null;
} catch (error) {
logError("Error reading from project context memory cache:", error);
return null;
}
}
async set(project: ProjectConfig, context: string): Promise<void> {
try {
await this.ensureCacheDir();
const cacheKey = this.getCacheKey(project);
const cachePath = this.getCachePath(cacheKey);
logInfo("Caching context for project:", project.name);
// Store in memory cache
this.memoryCache.set(cacheKey, context);
// Store in file cache
await app.vault.adapter.write(
cachePath,
JSON.stringify({
context,
timestamp: Date.now(),
})
);
} catch (error) {
logError("Error writing to project context cache:", error);
}
}
async clear(): Promise<void> {
try {
// Clear memory cache
this.memoryCache.clear();
// Clear file cache
if (await app.vault.adapter.exists(this.cacheDir)) {
const files = await app.vault.adapter.list(this.cacheDir);
logInfo("Clearing project context cache, removing files:", files.files.length);
for (const file of files.files) {
await app.vault.adapter.remove(file);
}
}
} catch (error) {
logError("Error clearing project context cache:", error);
}
}
async clearForProject(project: ProjectConfig): Promise<void> {
try {
const cacheKey = this.getCacheKey(project);
// Clear from memory cache
this.memoryCache.delete(cacheKey);
// Clear from file cache
const cachePath = this.getCachePath(cacheKey);
if (await app.vault.adapter.exists(cachePath)) {
logInfo("Clearing cache for project:", project.name);
await app.vault.adapter.remove(cachePath);
}
} catch (error) {
logError("Error clearing cache for project:", error);
}
}
}

View file

@ -54,6 +54,7 @@ export enum ChainType {
LLM_CHAIN = "llm_chain",
VAULT_QA_CHAIN = "vault_qa",
COPILOT_PLUS_CHAIN = "copilot_plus",
PROJECT_CHAIN = "project",
}
class ChainFactory {

View file

@ -1,6 +1,6 @@
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { removeThinkTags } from "@/utils";
import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models";
import ProjectManager from "@/LLMProviders/projectManager";
export async function getStandaloneQuestion(
question: string,
@ -22,8 +22,9 @@ export async function getStandaloneQuestion(
.map(([human, ai]) => `Human: ${human}\nAssistant: ${ai}`)
.join("\n");
const chatModel = ChatModelManager.getInstance()
.getChatModel()
const chatModel = ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModel()
.bind({ temperature: 0 } as BaseChatModelCallOptions);
const response = await chatModel.invoke([
{

View file

@ -124,14 +124,18 @@ export function registerCommands(
addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => {
const selectedText = await editor.getSelection();
const wordCount = selectedText.split(" ").length;
const tokenCount = await plugin.chainManager.chatModelManager.countTokens(selectedText);
const tokenCount = await plugin.projectManager
.getCurrentChainManager()
.chatModelManager.countTokens(selectedText);
new Notice(`Selected text contains ${wordCount} words and ${tokenCount} tokens.`);
});
addCommand(plugin, COMMAND_IDS.COUNT_TOTAL_VAULT_TOKENS, async () => {
try {
const allContent = await getAllQAMarkdownContent(plugin.app);
const totalTokens = await plugin.chainManager.chatModelManager.countTokens(allContent);
const totalTokens = await plugin.projectManager
.getCurrentChainManager()
.chatModelManager.countTokens(allContent);
new Notice(`Total tokens in your vault: ${totalTokens}`);
} catch (error) {
console.error("Error counting tokens: ", error);

View file

@ -1,9 +1,17 @@
import { useChainType, useModelKey } from "@/aiParams";
import {
getCurrentProject,
ProjectConfig,
setCurrentProject,
useChainType,
useModelKey,
} from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { updateChatMemory } from "@/chatUtils";
import { ChatControls } from "@/components/chat-components/ChatControls";
import ChatInput from "@/components/chat-components/ChatInput";
import ChatMessages from "@/components/chat-components/ChatMessages";
import { ProjectList } from "@/components/chat-components/ProjectList";
import { resetComposerPromptCache } from "@/composerUtils";
import { ABORT_REASON, COMMAND_IDS, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
import { AppContext, EventTargetContext } from "@/context";
import { ContextProcessor } from "@/contextProcessor";
@ -12,13 +20,15 @@ import { getAIResponse } from "@/langchainStream";
import ChainManager from "@/LLMProviders/chainManager";
import CopilotPlugin from "@/main";
import { Mention } from "@/mentions/Mention";
import { getSettings, useSettingsValue } from "@/settings/model";
import { getSettings, updateSetting, useSettingsValue } from "@/settings/model";
import SharedState, { ChatMessage, useSharedState } from "@/sharedState";
import { FileParserManager } from "@/tools/FileParserManager";
import { err2String, formatDateTime } from "@/utils";
import { Buffer } from "buffer";
import { Notice, TFile } from "obsidian";
import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Buffer } from "buffer";
type ChatMode = "default" | "project";
interface ChatProps {
sharedState: SharedState;
@ -27,6 +37,7 @@ interface ChatProps {
updateUserMessageHistory: (newMessage: string) => void;
fileParserManager: FileParserManager;
plugin: CopilotPlugin;
mode?: ChatMode;
}
const Chat: React.FC<ChatProps> = ({
@ -51,6 +62,10 @@ const Chat: React.FC<ChatProps> = ({
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
const [mode, setSelectedChain] = useChainType();
const mention = Mention.getInstance();
@ -533,6 +548,47 @@ ${chatContent}`;
[addMessage, chainManager.memoryManager, chatHistory, clearMessages]
);
const handleAddProject = useCallback(
(project: ProjectConfig) => {
const currentProjects = settings.projectList || [];
const existingIndex = currentProjects.findIndex((p) => p.name === project.name);
if (existingIndex >= 0) {
throw new Error(`Project "${project.name}" already exists, please use a different name`);
}
const newProjectList = [...currentProjects, project];
updateSetting("projectList", newProjectList);
new Notice(`${project.name} added successfully`);
return true;
},
[settings.projectList]
);
const handleEditProject = useCallback(
(originP: ProjectConfig, updateP: ProjectConfig) => {
const currentProjects = settings.projectList || [];
const existingProject = currentProjects.find((p) => p.name === originP.name);
if (!existingProject) {
throw new Error(`Project "${originP.name}" does not exist`);
}
const newProjectList = currentProjects.map((p) => (p.name === originP.name ? updateP : p));
updateSetting("projectList", newProjectList);
// If this is the current project, update the current project atom
const currentProject = getCurrentProject();
if (currentProject?.id === originP.id) {
setCurrentProject(updateP);
}
new Notice(`${originP.name} updated successfully`);
return true;
},
[settings.projectList]
);
const handleInsertToChat = useCallback((prompt: string) => {
setInputMessage((prev) => `${prev} ${prompt} `);
}, []);
@ -544,6 +600,8 @@ ${chatContent}`;
}
clearMessages();
chainManager.memoryManager.clearChatMemory();
// Reset the composer prompt cache when starting a new chat
resetComposerPromptCache();
setCurrentAiMessage("");
setContextNotes([]);
setIncludeActiveNote(false);
@ -556,22 +614,35 @@ ${chatContent}`;
handleSaveAsNote,
]);
return (
<div className="chat-container">
<ChatMessages
chatHistory={chatHistory}
currentAiMessage={currentAiMessage}
loading={loading}
loadingMessage={loadingMessage}
app={app}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onInsertToChat={handleInsertToChat}
onReplaceChat={setInputMessage}
/>
<div className="bottom-container">
<ChatControls onNewChat={handleNewChat} onSaveAsNote={() => handleSaveAsNote(true)} />
const renderChatComponents = () => (
<>
<div className="flex-1 overflow-y-auto">
<ChatMessages
chatHistory={chatHistory}
currentAiMessage={currentAiMessage}
loading={loading}
loadingMessage={loadingMessage}
app={app}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onInsertToChat={handleInsertToChat}
onReplaceChat={setInputMessage}
showHelperComponents={mode !== "project"}
/>
</div>
<div className="flex flex-col items-center justify-end w-full flex-none box-border sticky bottom-0 border-t border-border">
<ChatControls
onNewChat={handleNewChat}
onSaveAsNote={() => handleSaveAsNote(true)}
onModeChange={(newMode) => {
setPreviousMode(mode);
// Hide chat UI when switching to project mode
if (newMode === ChainType.PROJECT_CHAIN) {
setShowChatUI(false);
}
}}
/>
<ChatInput
ref={inputRef}
inputMessage={inputMessage}
@ -589,8 +660,44 @@ ${chatContent}`;
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
disableModelSwitch={mode === "project"}
/>
</div>
</>
);
return (
<div className="chat-container">
<div className="h-full">
<div className="flex flex-col h-full relative">
{mode === ChainType.PROJECT_CHAIN && (
<div className={`${mode === ChainType.PROJECT_CHAIN ? "z-modal" : ""}`}>
<ProjectList
projects={settings.projectList || []}
defaultOpen={true}
app={app}
hasMessages={false}
onProjectAdded={handleAddProject}
onEditProject={handleEditProject}
inputRef={inputRef}
onClose={() => {
if (previousMode) {
setSelectedChain(previousMode);
setPreviousMode(null);
} else {
// default back to chat or plus mode
setSelectedChain(
settings.isPlusUser ? ChainType.COPILOT_PLUS_CHAIN : ChainType.LLM_CHAIN
);
}
}}
showChatUI={(v) => setShowChatUI(v)}
/>
</div>
)}
{(mode !== "project" || (mode === "project" && showChatUI)) && renderChatComponents()}
</div>
</div>
</div>
);
};

View file

@ -8,10 +8,13 @@ import { FileParserManager } from "@/tools/FileParserManager";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ItemView, WorkspaceLeaf } from "obsidian";
import * as React from "react";
import { Root, createRoot } from "react-dom/client";
import { createRoot, Root } from "react-dom/client";
export default class CopilotView extends ItemView {
private chainManager: ChainManager;
private get chainManager(): ChainManager {
return this.plugin.projectManager.getCurrentChainManager();
}
private fileParserManager: FileParserManager;
private root: Root | null = null;
private handleSaveAsNote: (() => Promise<void>) | null = null;
@ -25,7 +28,6 @@ export default class CopilotView extends ItemView {
super(leaf);
this.sharedState = plugin.sharedState;
this.app = plugin.app;
this.chainManager = plugin.chainManager;
this.fileParserManager = plugin.fileParserManager;
this.eventTarget = new EventTarget();
this.plugin = plugin;
@ -50,14 +52,24 @@ export default class CopilotView extends ItemView {
}
async onOpen(): Promise<void> {
const root = createRoot(this.containerEl.children[1]);
this.root = createRoot(this.containerEl.children[1]);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
root.render(
this.renderView(handleSaveAsNote, updateUserMessageHistory);
}
private renderView(
handleSaveAsNote: (saveFunction: () => Promise<void>) => void,
updateUserMessageHistory: (newMessage: string) => void
): void {
if (!this.root) return;
this.root.render(
<AppContext.Provider value={this.app}>
<EventTargetContext.Provider value={this.eventTarget}>
<React.StrictMode>
@ -83,14 +95,26 @@ export default class CopilotView extends ItemView {
}
}
updateView(): void {
// load currentChainManager chatMessages
this.sharedState.replaceMessages(
this.plugin.projectManager.getCurrentChainManager().getChatMessages()
);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
this.renderView(handleSaveAsNote, updateUserMessageHistory);
}
async onClose(): Promise<void> {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
updateView(): void {
// Force a re-render of the React component
this.onOpen();
}
}

View file

@ -1,12 +1,13 @@
import React from "react";
import {
Download,
MoreHorizontal,
Sparkles,
FileText,
RefreshCw,
MessageCirclePlus,
ChevronDown,
Download,
FileText,
LibraryBig,
MessageCirclePlus,
MoreHorizontal,
RefreshCw,
Sparkles,
SquareArrowOutUpRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -15,7 +16,7 @@ import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-
import { SettingSwitch } from "@/components/ui/setting-switch";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useChainType } from "@/aiParams";
import { setCurrentProject, useChainType } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { Notice } from "obsidian";
import VectorStoreManager from "@/search/vectorStoreManager";
@ -35,13 +36,29 @@ export async function refreshVaultIndex() {
interface ChatControlsProps {
onNewChat: () => void;
onSaveAsNote: () => void;
onModeChange: (mode: ChainType) => void;
onCloseProject?: () => void;
}
export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
export function ChatControls({
onNewChat,
onSaveAsNote,
onModeChange,
onCloseProject,
}: ChatControlsProps) {
const settings = useSettingsValue();
const [selectedChain, setSelectedChain] = useChainType();
const isPlusUser = useIsPlusUser();
const handleModeChange = (chainType: ChainType) => {
setSelectedChain(chainType);
onModeChange(chainType);
if (chainType !== ChainType.PROJECT_CHAIN) {
setCurrentProject(null);
onCloseProject?.();
}
};
return (
<div className="w-full py-1 flex justify-between items-center px-1">
<div className="flex-1">
@ -56,18 +73,31 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
copilot plus (beta)
</div>
)}
{selectedChain === ChainType.PROJECT_CHAIN && "plus projects (alpha)"}
<ChevronDown className="size-5 mt-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.LLM_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.LLM_CHAIN);
}}
>
chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.VAULT_QA_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.VAULT_QA_CHAIN);
}}
>
vault QA
</DropdownMenuItem>
{isPlusUser ? (
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.COPILOT_PLUS_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
}}
>
<div className="flex items-center gap-1">
<Sparkles className="size-4" />
copilot plus (beta)
@ -77,6 +107,29 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
<DropdownMenuItem
onSelect={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT);
onCloseProject?.();
}}
>
copilot plus (beta)
<SquareArrowOutUpRight className="size-3" />
</DropdownMenuItem>
)}
{isPlusUser ? (
<DropdownMenuItem
className="flex items-center gap-1"
onSelect={() => {
handleModeChange(ChainType.PROJECT_CHAIN);
}}
>
<LibraryBig className="size-4" />
plus projects (alpha)
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT);
onCloseProject?.();
}}
>
copilot plus (beta)

View file

@ -1,4 +1,11 @@
import { useChainType, useModelKey } from "@/aiParams";
import {
useChainType,
useModelKey,
useProjectLoading,
ProjectConfig,
getCurrentProject,
subscribeToProjectChange,
} from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
import { AddImageModal } from "@/components/modals/AddImageModal";
@ -24,9 +31,9 @@ import {
Command,
CornerDownLeft,
Image,
Loader2,
StopCircle,
X,
Loader2,
} from "lucide-react";
import { App, Notice, Platform, TFile } from "obsidian";
import React, {
@ -61,6 +68,7 @@ interface ChatInputProps {
selectedImages: File[];
onAddImage: (files: File[]) => void;
setSelectedImages: React.Dispatch<React.SetStateAction<File[]>>;
disableModelSwitch?: boolean;
}
const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
@ -81,6 +89,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
selectedImages,
onAddImage,
setSelectedImages,
disableModelSwitch,
},
ref
) => {
@ -93,11 +102,14 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
const [currentModelKey, setCurrentModelKey] = useModelKey();
const [modelError, setModelError] = useState<string | null>(null);
const [currentChain] = useChainType();
const [isProjectLoading] = useProjectLoading();
const [currentActiveNote, setCurrentActiveNote] = useState<TFile | null>(
app.workspace.getActiveFile()
);
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
const settings = useSettingsValue();
const isCopilotPlus = currentChain === ChainType.COPILOT_PLUS_CHAIN;
const isCopilotPlus =
currentChain === ChainType.COPILOT_PLUS_CHAIN || currentChain === ChainType.PROJECT_CHAIN;
useImperativeHandle(ref, () => ({
focus: () => {
@ -105,6 +117,33 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
},
}));
useEffect(() => {
if (currentChain === ChainType.PROJECT_CHAIN) {
setSelectedProject(getCurrentProject());
const unsubscribe = subscribeToProjectChange((project) => {
setSelectedProject(project);
});
return () => {
unsubscribe();
};
} else {
setSelectedProject(null);
}
}, [currentChain]);
const getDisplayModelKey = (): string => {
if (
selectedProject &&
currentChain === ChainType.PROJECT_CHAIN &&
selectedProject.projectModelKey
) {
return selectedProject.projectModelKey;
}
return currentModelKey;
};
const onSendMessage = (includeVault: boolean) => {
if (!isCopilotPlus) {
handleSendMessage();
@ -458,6 +497,14 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
)}
<div className="relative" {...(isCopilotPlus ? getRootProps() : {})}>
{isProjectLoading && (
<div className="absolute inset-0 z-modal bg-background/80 backdrop-blur-sm flex items-center justify-center">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading the project context...</span>
</div>
</div>
)}
<textarea
ref={textAreaRef}
className="w-full bg-transparent focus-visible:ring-0 border-none min-h-[60px] max-h-40 overflow-y-auto resize-none px-2 rounded-md text-sm text-normal"
@ -469,6 +516,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={isProjectLoading}
/>
{isCopilotPlus && (
<>
@ -492,17 +540,18 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
) : (
<DropdownMenu open={isModelDropdownOpen} onOpenChange={setIsModelDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost2" size="fit">
<Button variant="ghost2" size="fit" disabled={disableModelSwitch}>
{modelError ? (
<span className="text-error">Model Load Failed</span>
) : settings.activeModels.find(
(model) => model.enabled && getModelKeyFromModel(model) === currentModelKey
(model) =>
model.enabled && getModelKeyFromModel(model) === getDisplayModelKey()
) ? (
<ModelDisplay
model={
settings.activeModels.find(
(model) =>
model.enabled && getModelKeyFromModel(model) === currentModelKey
model.enabled && getModelKeyFromModel(model) === getDisplayModelKey()
)!
}
iconSize={8}
@ -510,47 +559,48 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
) : (
"Select Model"
)}
<ChevronDown className="size-5 mt-0.5" />
{!disableModelSwitch && <ChevronDown className="size-5 mt-0.5" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{settings.activeModels
.filter((model) => model.enabled)
.map((model) => {
const { hasApiKey, errorNotice } = checkModelApiKey(model, settings);
return (
<DropdownMenuItem
key={getModelKeyFromModel(model)}
onSelect={async (event) => {
if (!hasApiKey && errorNotice) {
event.preventDefault();
new Notice(errorNotice);
return;
}
try {
setModelError(null);
setCurrentModelKey(getModelKeyFromModel(model));
} catch (error) {
const msg = `Model switch failed: ` + err2String(error);
setModelError(msg);
new Notice(msg);
// Restore to the last valid model
const lastValidModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === currentModelKey
);
if (lastValidModel) {
setCurrentModelKey(getModelKeyFromModel(lastValidModel));
{!disableModelSwitch &&
settings.activeModels
.filter((model) => model.enabled)
.map((model) => {
const { hasApiKey, errorNotice } = checkModelApiKey(model, settings);
return (
<DropdownMenuItem
key={getModelKeyFromModel(model)}
onSelect={async (event) => {
if (!hasApiKey && errorNotice) {
event.preventDefault();
new Notice(errorNotice);
return;
}
}
}}
className={!hasApiKey ? "opacity-50 cursor-not-allowed" : ""}
>
<ModelDisplay model={model} iconSize={12} />
</DropdownMenuItem>
);
})}
try {
setModelError(null);
setCurrentModelKey(getModelKeyFromModel(model));
} catch (error) {
const msg = `Model switch failed: ` + err2String(error);
setModelError(msg);
new Notice(msg);
// Restore to the last valid model
const lastValidModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === currentModelKey
);
if (lastValidModel) {
setCurrentModelKey(getModelKeyFromModel(lastValidModel));
}
}
}}
className={!hasApiKey ? "opacity-50 cursor-not-allowed" : ""}
>
<ModelDisplay model={model} iconSize={12} />
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}

View file

@ -17,6 +17,7 @@ interface ChatMessagesProps {
onDelete: (messageIndex: number) => void;
onInsertToChat: (prompt: string) => void;
onReplaceChat: (prompt: string) => void;
showHelperComponents: boolean;
}
const ChatMessages = memo(
@ -31,6 +32,7 @@ const ChatMessages = memo(
onDelete,
onInsertToChat,
onReplaceChat,
showHelperComponents = true,
}: ChatMessagesProps) => {
const [loadingDots, setLoadingDots] = useState("");
@ -63,14 +65,16 @@ const ChatMessages = memo(
if (!chatHistory.filter((message) => message.isVisible).length && !currentAiMessage) {
return (
<div className="flex flex-col gap-2 overflow-y-auto w-full h-full">
{settings.showRelevantNotes && (
{showHelperComponents && settings.showRelevantNotes && (
<RelevantNotes
onInsertToChat={onInsertToChat}
defaultOpen={true}
key="relevant-notes-before-chat"
/>
)}
{settings.showSuggestedPrompts && <SuggestedPrompts onClick={onReplaceChat} />}
{showHelperComponents && settings.showSuggestedPrompts && (
<SuggestedPrompts onClick={onReplaceChat} />
)}
</div>
);
}
@ -81,7 +85,7 @@ const ChatMessages = memo(
return (
<div className="flex flex-col flex-1 h-full overflow-hidden">
{settings.showRelevantNotes && (
{showHelperComponents && settings.showRelevantNotes && (
<RelevantNotes
className="mb-4"
onInsertToChat={onInsertToChat}
@ -104,6 +108,7 @@ const ChatMessages = memo(
onRegenerate={() => onRegenerate(index)}
onEdit={(newMessage) => onEdit(index, newMessage)}
onDelete={() => onDelete(index)}
chatHistory={chatHistory}
/>
)
)}
@ -119,6 +124,7 @@ const ChatMessages = memo(
app={app}
isStreaming={true}
onDelete={() => {}}
chatHistory={chatHistory}
/>
)}
</div>

View file

@ -3,12 +3,15 @@ import { SourcesModal } from "@/components/modals/SourcesModal";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { USER_SENDER } from "@/constants";
import { useApplyCode } from "@/hooks/useApplyCode";
import { cn } from "@/lib/utils";
import { ChatMessage } from "@/sharedState";
import { insertIntoEditor } from "@/utils";
import { Bot, User } from "lucide-react";
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { CodeBlock } from "./CodeBlock";
function MessageContext({ context }: { context: ChatMessage["context"] }) {
if (!context || (context.notes.length === 0 && context.urls.length === 0)) {
@ -48,6 +51,7 @@ interface ChatSingleMessageProps {
onRegenerate?: () => void;
onEdit?: (newMessage: string) => void;
onDelete: () => void;
chatHistory?: ChatMessage[];
}
const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
@ -57,6 +61,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
onRegenerate,
onEdit,
onDelete,
chatHistory = [],
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
@ -65,6 +70,8 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const componentRef = useRef<Component | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleApplyCode = useApplyCode(app, chatHistory);
const copyToClipboard = () => {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
return;
@ -199,8 +206,10 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
};
useEffect(() => {
const roots: Root[] = [];
let isUnmounting = false;
if (contentRef.current && message.sender !== USER_SENDER) {
// Clear previous content
contentRef.current.innerHTML = "";
// Create a new Component instance if it doesn't exist
@ -210,23 +219,77 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
const processedMessage = preprocess(message.message);
// Use Obsidian's MarkdownRenderer to render the message
MarkdownRenderer.renderMarkdown(
processedMessage,
contentRef.current,
"", // Empty string for sourcePath as we don't have a specific source file
componentRef.current
);
if (!isUnmounting) {
// Use Obsidian's MarkdownRenderer to render the message
MarkdownRenderer.renderMarkdown(
processedMessage,
contentRef.current,
"", // Empty string for sourcePath as we don't have a specific source file
componentRef.current
);
// Process code blocks after rendering
const codeBlocks = contentRef.current.querySelectorAll("pre");
if (codeBlocks.length > 0) {
codeBlocks.forEach((pre) => {
if (isUnmounting) return;
const codeElement = pre.querySelector("code");
if (!codeElement) return;
const originalCode = codeElement.textContent || "";
const lines = originalCode.split("\n");
const firstLine = lines[0].trim();
// Check for path in HTML comment format: <!-- path=Notes/My Notes.md -->
const htmlCommentMatch = firstLine.match(/<!--\s*path=([^>]+?)\s*-->/);
if (htmlCommentMatch && htmlCommentMatch[1]) {
const path = htmlCommentMatch[1].trim();
const cleanedCode = lines.slice(1).join("\n");
// Create a container for the React component
const container = document.createElement("div");
pre.parentNode?.replaceChild(container, pre);
// Create a root and render the CodeBlock component
const root = createRoot(container);
roots.push(root);
if (!isUnmounting) {
root.render(
<CodeBlock
code={cleanedCode}
path={path}
onApply={isStreaming ? undefined : handleApplyCode}
/>
);
}
}
});
}
}
}
// Cleanup function
return () => {
if (componentRef.current) {
componentRef.current.unload();
componentRef.current = null;
}
isUnmounting = true;
// Schedule cleanup to run after current render cycle
setTimeout(() => {
if (componentRef.current) {
componentRef.current.unload();
componentRef.current = null;
}
roots.forEach((root) => {
try {
root.unmount();
} catch {
// Ignore unmount errors during cleanup
}
});
}, 0);
};
}, [message, app, componentRef, isStreaming, preprocess]);
}, [message, app, componentRef, isStreaming, preprocess, handleApplyCode]);
useEffect(() => {
if (isEditing && textareaRef.current) {
@ -361,7 +424,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
)}
>
<div className="w-6 shrink-0">{message.sender === USER_SENDER ? <User /> : <Bot />}</div>
<div className="flex flex-col flex-grow max-w-full gap-2">
<div className="flex flex-col flex-grow max-w-full gap-2 overflow-hidden">
{!isEditing && <MessageContext context={message.context} />}
<div className="message-content">{renderMessageContent()}</div>

View file

@ -0,0 +1,54 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Check, Loader2 } from "lucide-react";
interface CodeBlockProps {
code: string;
path?: string;
onApply?: (path: string, code: string) => Promise<void>;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, path, onApply }) => {
const [isApplying, setIsApplying] = useState(false);
const handleApply = async () => {
if (!path || !onApply) return;
setIsApplying(true);
try {
await onApply(path, code);
} finally {
setIsApplying(false);
}
};
return (
<div className="border border-border border-solid rounded-md my-2 flex flex-col overflow-hidden">
{path && (
<div className="flex justify-between items-center border-[0px] border-b border-border border-solid gap-2 p-2 overflow-hidden">
<div className="text-xs p-1 text-muted-foreground truncate flex-1">{path}</div>
{onApply && (
<Button
className="text-muted"
variant="ghost2"
size="fit"
onClick={handleApply}
disabled={isApplying}
>
{isApplying ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
Apply
</Button>
)}
</div>
)}
<pre className="m-0 border-none">
<code>{code}</code>
</pre>
</div>
);
};

View file

@ -0,0 +1,404 @@
import { ProjectConfig, setCurrentProject } from "@/aiParams";
import { AddProjectModal } from "@/components/modals/AddProjectModal";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { updateSetting } from "@/settings/model";
import {
ChevronDown,
ChevronUp,
Edit2,
Folder,
Info,
MessageSquare,
Plus,
Trash2,
X,
} from "lucide-react";
import { App, Notice } from "obsidian";
import React, { memo, useEffect, useState } from "react";
function ProjectItem({
project,
loadContext,
onEdit,
onDelete,
}: {
project: ProjectConfig;
loadContext: (project: ProjectConfig) => void;
onEdit: (project: ProjectConfig) => void;
onDelete: (project: ProjectConfig) => void;
}) {
return (
<div
className="flex gap-2 p-3 justify-between items-center rounded-lg bg-secondary/40 border border-border border-solid group transition-all duration-200 hover:border-interactive-accent/30 hover:bg-interactive-accent/5 hover:shadow-[0_2px_12px_rgba(0,0,0,0.1)] active:scale-[0.98] cursor-pointer"
onClick={() => loadContext(project)}
>
<div className="flex items-center gap-2 flex-1 overflow-hidden">
<div className="text-blue-400">
<Folder className="size-4" />
</div>
<div className="flex flex-col gap-1.5 flex-1 overflow-hidden">
<span className="text-[13px] font-medium text-normal text-ellipsis overflow-hidden whitespace-nowrap w-full">
{project.name}
</span>
{project.description && (
<span className="text-[12px] text-muted/80 text-ellipsis overflow-hidden whitespace-nowrap w-full">
{project.description}
</span>
)}
</div>
</div>
<div className="flex flex-row gap-1 opacity-100 transition-opacity duration-200">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
className="h-6 w-6 hover:bg-accent/10 hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit(project);
}}
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit Project</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
className="h-6 w-6 hover:bg-accent/10 hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation();
loadContext(project);
}}
>
<MessageSquare className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Start Chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
className="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
const modal = new ConfirmModal(
app,
() => onDelete(project),
`Are you sure you want to delete project "${project.name}"?`,
"Delete Project"
);
modal.open();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete Project</TooltipContent>
</Tooltip>
</div>
</div>
);
}
export const ProjectList = memo(
({
className,
projects,
defaultOpen = false,
app,
onProjectAdded,
onEditProject,
hasMessages = false,
showChatUI,
onClose,
inputRef,
}: {
className?: string;
projects: ProjectConfig[];
defaultOpen?: boolean;
app: App;
onProjectAdded: (project: ProjectConfig) => void;
onEditProject: (originP: ProjectConfig, updateP: ProjectConfig) => void;
hasMessages?: boolean;
showChatUI: (v: boolean) => void;
onClose: () => void;
inputRef: React.RefObject<HTMLTextAreaElement>;
}): React.ReactElement => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showChatInput, setShowChatInput] = useState(false);
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
// Auto collapse when messages appear
useEffect(() => {
if (hasMessages) {
setIsOpen(false);
}
}, [hasMessages]);
const handleAddProject = () => {
const modal = new AddProjectModal(app, async (project: ProjectConfig) => {
onProjectAdded(project);
});
modal.open();
};
const handleEditProject = (originP: ProjectConfig) => {
const modal = new AddProjectModal(
app,
async (updatedProject: ProjectConfig) => {
onEditProject(originP, updatedProject);
if (selectedProject && selectedProject.name === originP.name) {
setSelectedProject(updatedProject);
}
},
originP
);
modal.open();
};
const handleDeleteProject = (project: ProjectConfig) => {
const currentProjects = projects || [];
const newProjectList = currentProjects.filter((p) => p.name !== project.name);
// If the deleted project is currently selected, close it
if (selectedProject?.name === project.name) {
enableOrDisableProject(false);
}
// Update the project list in settings
updateSetting("projectList", newProjectList);
new Notice(`Project "${project.name}" deleted successfully`);
};
const enableOrDisableProject = (enable: boolean, project?: ProjectConfig) => {
if (!enable) {
setSelectedProject(null);
setShowChatInput(false);
setIsOpen(true);
showChatUI(false);
setCurrentProject(null);
return;
} else {
if (!project) {
logError("Must be exist one project.");
return;
}
setSelectedProject(project);
setShowChatInput(true);
setIsOpen(false);
}
};
const handleLoadContext = (p: ProjectConfig) => {
setSelectedProject(p);
setShowChatInput(true);
setIsOpen(false);
showChatUI(true);
setCurrentProject(p);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
};
return (
<div className={cn("flex flex-col", className)}>
<div className="overflow-y-auto">
<div className="flex flex-col">
{showChatInput && selectedProject ? (
<div className="flex justify-between items-center px-2 py-3">
<div className="flex gap-2 items-center">
<span className="font-semibold text-normal">Projects</span>
<Select
value={selectedProject.name}
onValueChange={(value) => {
const project = projects.find((p) => p.name === value);
if (project) {
handleLoadContext(project);
}
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue>
<div className="flex items-center gap-2">
<Folder className="size-4 text-accent/70" />
<span>{selectedProject.name}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.name} value={project.name}>
<div className="flex items-center gap-2">
<Folder className="size-4" />
<span>{project.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="fit"
variant="ghost2"
onClick={() => handleEditProject(selectedProject)}
className="hover:bg-accent/50 hover:text-on-accent"
>
<Edit2 className="size-4 mr-1" />
Edit
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit Current Project</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() => {
enableOrDisableProject(false);
}}
aria-label="Close Current Project"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close Current Project</TooltipContent>
</Tooltip>
</div>
</div>
) : (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="transition-all duration-200 ease-in-out"
>
<div className="flex justify-between items-center px-4 py-3">
<div className="flex gap-2 items-center flex-1">
<span className="font-semibold text-normal">Projects</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted" />
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64">
Manage your projects with different contexts and configurations.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button size="fit" className="px-2" onClick={handleAddProject}>
Create
<Plus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Add New Project</TooltipContent>
</Tooltip>
{projects.length > 0 && (
<CollapsibleTrigger asChild>
<Button variant="ghost2" size="icon">
{isOpen ? (
<ChevronUp className="size-5" />
) : (
<ChevronDown className="size-5" />
)}
</Button>
</CollapsibleTrigger>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() => onClose()}
aria-label="close project mode"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close Project Mode</TooltipContent>
</Tooltip>
</div>
</div>
{projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted bg-secondary/30">
No projects available
</div>
)}
<CollapsibleContent className="transition-all duration-200 ease-in-out">
<div className="relative bg-secondary/30">
<div className="px-4 pt-3 pb-6 max-h-[calc(3*5.7rem)] overflow-y-auto">
<div className="flex flex-col gap-2 @2xl:grid @2xl:grid-cols-2 @4xl:grid-cols-3">
{projects.map((project) => (
<ProjectItem
key={project.name}
project={project}
loadContext={handleLoadContext}
onEdit={handleEditProject}
onDelete={handleDeleteProject}
/>
))}
</div>
</div>
{projects.length > 0 && (
<div
className="absolute bottom-0 left-0 right-0 h-6"
style={{
background: "linear-gradient(transparent, var(--background-primary) 75%)",
}}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{!showChatInput && (
<div className="flex flex-col gap-4 items-center justify-center p-8 text-muted bg-secondary/30">
<div className="max-w-[600px] space-y-4">
<p className="text-base text-center">
Create your project-based AI assistants with custom instructions, context, and
model configurations.
</p>
<div className="flex flex-col gap-3 text-sm">
<div className="flex items-center gap-2">
<MessageSquare className="size-4" />
<span>Click a project card to start chatting</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
);
ProjectList.displayName = "ProjectList";

View file

@ -65,6 +65,7 @@ const PROMPT_KEYS: Record<ChainType, Array<keyof typeof SUGGESTED_PROMPTS>> = {
[ChainType.LLM_CHAIN]: ["activeNote", "quoteNote", "fun"],
[ChainType.VAULT_QA_CHAIN]: ["qaVault", "qaVault", "quoteNote"],
[ChainType.COPILOT_PLUS_CHAIN]: ["copilotPlus", "copilotPlus", "copilotPlus"],
[ChainType.PROJECT_CHAIN]: ["copilotPlus", "copilotPlus", "copilotPlus"],
};
function getRandomPrompt(chainType: ChainType = ChainType.LLM_CHAIN) {

View file

@ -0,0 +1,436 @@
import { ApplyChangesConfirmModal } from "@/components/modals/ApplyChangesConfirmModal";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { Change, diffLines } from "diff";
import { Check, X as XIcon } from "lucide-react";
import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian";
import React, { useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { Button } from "../ui/button";
export const APPLY_VIEW_TYPE = "obsidian-copilot-apply-view";
export interface ApplyViewState {
file: TFile;
originalContent: string;
newContent: string;
path?: string;
}
// Extended Change interface to track user decisions
interface ExtendedChange extends Change {
accepted: boolean | null;
}
export class ApplyView extends ItemView {
private root: ReturnType<typeof createRoot> | null = null;
private state: ApplyViewState | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return APPLY_VIEW_TYPE;
}
getDisplayText(): string {
return "Apply Changes";
}
async setState(state: ApplyViewState) {
this.state = state;
this.render();
}
async onOpen() {
this.render();
}
async onClose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
private render() {
if (!this.state) return;
// The second child is the actual content of the view, and the first child is the title of the view
// NOTE: While no official documentation is found, this seems like a standard pattern across community plugins.
const contentEl = this.containerEl.children[1];
contentEl.empty();
const rootEl = contentEl.createDiv();
if (!this.root) {
this.root = createRoot(rootEl);
}
this.root.render(
<ApplyViewRoot app={this.app} state={this.state} close={() => this.leaf.detach()} />
);
}
}
interface ApplyViewRootProps {
app: App;
state: ApplyViewState;
close: () => void;
}
const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
// Initialize diff with extended properties - moved before conditional
const [diff, setDiff] = useState<ExtendedChange[]>(() => {
if (!state?.originalContent || !state?.newContent) {
return [];
}
const initialDiff = diffLines(state.originalContent, state.newContent);
return initialDiff.map((change) => ({
...change,
accepted: null, // Start with null (undecided)
}));
});
const undecidedChanges = diff.filter(
(change) => (change.added || change.removed) && change.accepted === null
);
const hasAnyDecidedChanges = diff.some((change) => change.accepted !== null);
// Group changes into blocks for better UI presentation
const changeBlocks = useMemo(() => {
if (!diff.length) return;
const blocks: ExtendedChange[][] = [];
let currentBlock: ExtendedChange[] = [];
let inChangeBlock = false;
diff.forEach((change) => {
if (change.added || change.removed) {
if (!inChangeBlock) {
inChangeBlock = true;
currentBlock = [];
}
currentBlock.push(change);
} else {
if (inChangeBlock) {
blocks.push([...currentBlock]);
currentBlock = [];
inChangeBlock = false;
}
blocks.push([change]);
}
});
if (currentBlock.length > 0) {
blocks.push(currentBlock);
}
return blocks;
}, [diff]);
// Add refs to track change blocks
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
// Add defensive check for state after hooks
if (!state || !state.originalContent || !state.newContent) {
logError("Invalid state:", state);
return (
<div className="flex flex-col h-full items-center justify-center">
<div className="text-error">Error: Invalid state - missing content</div>
<Button onClick={close} className="mt-4">
Close
</Button>
</div>
);
}
// Handle applying changes that have been marked as accepted
const handleApply = async () => {
try {
const applyChanges = async () => {
const newContent = diff
.filter((change) => {
if (change.added) return change.accepted === true;
else if (change.removed) return change.accepted === false;
return true; // Unchanged lines are always included
})
.map((change) => change.value)
.join("");
await app.vault.modify(state.file, newContent);
new Notice("Changes applied successfully");
close();
};
if (undecidedChanges.length > 0) {
// Divide by 2 because each change has a pair of added and removed lines
const modal = new ApplyChangesConfirmModal(app, undecidedChanges.length / 2, () => {
applyChanges();
});
modal.open();
return;
}
applyChanges();
} catch (error) {
logError("Error applying changes:", error);
new Notice(`Error applying changes: ${error.message}`);
}
};
// Apply all changes regardless of whether they have been marked as accepted
const handleAcceptAll = async () => {
try {
const newContent = diff
.filter((change) => {
return change.added || !change.removed;
})
.map((change) => change.value)
.join("");
await app.vault.modify(state.file, newContent);
new Notice("Changes applied successfully");
close();
} catch (error) {
logError("Error applying changes:", error);
new Notice(`Error applying changes: ${error.message}`);
}
};
// Handle rejecting all changes
const handleReject = () => {
close();
};
// Function to focus on the next change block or scroll to top if it's the last block
const focusNextChangeBlock = (currentBlockIndex: number) => {
if (!changeBlocks) return;
// Find the next block with changes that is undecided
let nextBlockIndex = -1;
for (let i = currentBlockIndex + 1; i < changeBlocks.length; i++) {
const block = changeBlocks[i];
const hasChanges = block.some((change) => change.added || change.removed);
const isUndecided = block.some(
(change) => (change.added || change.removed) && change.accepted === null
);
if (hasChanges && isUndecided) {
nextBlockIndex = i;
break;
}
}
// If there's a next block, scroll to it
if (nextBlockIndex !== -1 && blockRefs.current[nextBlockIndex]) {
blockRefs.current[nextBlockIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
// Accept a block of changes
const acceptBlock = (blockIndex: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
// Find the indices of the changes in this block
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: true,
};
}
});
return newDiff;
});
// Focus on the next change block after state update
setTimeout(() => focusNextChangeBlock(blockIndex), 0);
};
// Reject a block of changes
const rejectBlock = (blockIndex: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
// Find the indices of the changes in this block
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: false,
};
}
});
return newDiff;
});
// Focus on the next change block after state update
setTimeout(() => focusNextChangeBlock(blockIndex), 0);
};
return (
<div className="flex flex-col h-full relative">
<div className="flex z-[1] gap-2 fixed bottom-2 left-1/2 -translate-x-1/2 p-2 border border-solid border-border rounded-md bg-secondary">
<Button variant="destructive" size="sm" onClick={handleReject}>
<XIcon className="size-4" />
Reject All
</Button>
<Button variant="success" size="sm" onClick={handleAcceptAll}>
<Check className="size-4" />
Accept All
</Button>
<Button
disabled={!hasAnyDecidedChanges}
variant="secondary"
size="sm"
onClick={handleApply}
>
Apply Changes
</Button>
</div>
<div className="flex items-center p-2 border-[0px] border-solid border-b border-border text-sm font-medium">
{state.path || state.file.path}
</div>
<div className="flex-1 overflow-auto p-2">
{changeBlocks?.map((block, blockIndex) => {
// Check if this block contains any changes (added or removed)
const hasChanges = block.some((change) => change.added || change.removed);
// Get the decision status for this block
const blockStatus = hasChanges
? block.every(
(change) => (!change.added && !change.removed) || change.accepted === true
)
? "accepted"
: block.every(
(change) => (!change.added && !change.removed) || change.accepted === false
)
? "rejected"
: "undecided"
: "unchanged";
return (
<div
key={blockIndex}
ref={(el) => (blockRefs.current[blockIndex] = el)}
className={cn("mb-4 border rounded-md overflow-hidden border-border", {
"border-solid": blockStatus !== "unchanged",
})}
>
{blockStatus === "accepted" ? (
// Show only the accepted version
<div className="flex-1 font-mono text-sm whitespace-pre-wrap py-1 px-2 text-text-normal">
{block
.filter((change) => !change.removed)
.map((change, idx) => (
<div key={idx}>{change.value}</div>
))}
</div>
) : blockStatus === "rejected" ? (
// Show only the original version
<div className="flex-1 font-mono text-sm whitespace-pre-wrap py-1 px-2 text-text-normal">
{block
.filter((change) => !change.added)
.map((change, idx) => (
<div key={idx}>{change.value}</div>
))}
</div>
) : (
// Show the diff view for undecided blocks
block.map((change, changeIndex) => (
<div
key={`${blockIndex}-${changeIndex}`}
className={cn("flex relative border-l-3", {
"bg-success border-l-green": change.added,
"bg-error border-l-red": change.removed,
})}
>
<div className="flex-1 font-mono text-sm whitespace-pre-wrap py-1 px-2 text-text-normal">
{change.value}
</div>
</div>
))
)}
{/* Only show accept/reject buttons for blocks with changes that are undecided */}
{hasChanges && blockStatus === "undecided" && (
<div className="flex items-center justify-end p-2 border-[0px] border-solid border-t border-border">
<div className="flex items-center gap-2">
<Button variant="destructive" size="sm" onClick={() => rejectBlock(blockIndex)}>
<XIcon className="size-4" />
Reject
</Button>
<Button variant="success" size="sm" onClick={() => acceptBlock(blockIndex)}>
<Check className="size-4" />
Accept
</Button>
</div>
</div>
)}
{/* Show status for decided blocks with revert option */}
{hasChanges && (blockStatus === "accepted" || blockStatus === "rejected") && (
<div className="flex items-center justify-end p-2 border-[0px] border-solid border-t border-border">
<div className="flex items-center gap-2">
<div className="text-sm font-medium mr-2">
{blockStatus === "accepted" ? (
<div className="flex items-center gap-1 text-success">
<Check className="size-4" />
<div>Accepted</div>
</div>
) : (
<div className="flex items-center gap-1 text-error">
<XIcon className="size-4" />
<div>Rejected</div>
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
// Reset the block to undecided state
setDiff((prevDiff) => {
const newDiff = [...prevDiff];
const block = changeBlocks?.[blockIndex];
if (!block) return newDiff;
block.forEach((blockChange) => {
const index = newDiff.findIndex((change) => change === blockChange);
if (index !== -1) {
newDiff[index] = {
...newDiff[index],
accepted: null,
};
}
});
return newDiff;
});
}}
>
Revert
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,389 @@
import { ProjectConfig } from "@/aiParams";
import { PatternMatchingModal } from "@/components/modals/PatternMatchingModal";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { Input } from "@/components/ui/input";
import { getModelDisplayWithIcons } from "@/components/ui/model-display";
import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select";
import { SettingSlider } from "@/components/ui/setting-slider";
import { Textarea } from "@/components/ui/textarea";
import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
import { checkModelApiKey, err2String, randomUUID } from "@/utils";
import { App, Modal, Notice } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
interface AddProjectModalContentProps {
initialProject?: ProjectConfig;
onSave: (project: ProjectConfig) => Promise<void>;
onCancel: () => void;
}
function AddProjectModalContent({ initialProject, onSave, onCancel }: AddProjectModalContentProps) {
const settings = useSettingsValue();
const [isSubmitting, setIsSubmitting] = useState(false);
const [touched, setTouched] = useState({
name: false,
systemPrompt: false,
projectModelKey: false,
inclusions: false,
});
const [formData, setFormData] = useState<Partial<ProjectConfig>>(
initialProject || {
id: randomUUID(),
name: "",
description: "",
systemPrompt: "",
projectModelKey: "",
modelConfigs: {
temperature: 1.0,
maxTokens: 1000,
},
contextSource: {
inclusions: "",
exclusions: "",
webUrls: "",
youtubeUrls: "",
},
created: Date.now(),
UsageTimestamps: Date.now(),
}
);
const isFormValid = () => {
return formData.name && formData.projectModelKey;
};
const handleInputChange = (
field: string,
value: string | number | string[] | Record<string, any>
) => {
setFormData((prev) => {
// Handle text input
if (typeof value === "string") {
// Only trim for specific fields that shouldn't have whitespace
if (field === "name" || field === "projectModelKey") {
value = value.trim();
}
}
// Handle string arrays
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
value = value.map((item) => item.trim()).filter(Boolean);
}
if (field.includes(".")) {
const [parent, child] = field.split(".");
const parentKey = parent as keyof typeof prev;
const parentValue = prev[parentKey];
if (typeof parentValue === "object" && parentValue !== null) {
return {
...prev,
[parent]: {
...parentValue,
[child]: value,
},
};
}
}
return {
...prev,
[field]: value,
};
});
};
const handleSave = async () => {
const requiredFields = ["name", "projectModelKey"];
const missingFields = requiredFields.filter((field) => !formData[field as keyof ProjectConfig]);
if (missingFields.length > 0) {
setTouched((prev) => ({
...prev,
...Object.fromEntries(missingFields.map((field) => [field, true])),
}));
new Notice("Please fill in all required fields");
return;
}
try {
setIsSubmitting(true);
await onSave(formData as ProjectConfig);
} catch (e) {
new Notice(err2String(e));
setTouched((prev) => ({
...prev,
name: true,
}));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-2 p-4">
<div className="text-xl font-bold text-normal mb-2">Add New Project</div>
<div className="flex flex-col gap-2">
<FormField
label="Project Name"
required
error={touched.name && !formData.name}
errorMessage="Project name is required"
>
<Input
type="text"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, name: true }))}
className="w-full"
/>
</FormField>
<FormField
label="Description"
description="Briefly describe the purpose and goals of the project"
>
<Input
type="text"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
className="w-full"
/>
</FormField>
<FormField
label="Project System Prompt"
description="Custom instructions for how the AI should behave in this project context"
>
<Textarea
value={formData.systemPrompt}
onChange={(e) => handleInputChange("systemPrompt", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, systemPrompt: true }))}
className="min-h-[8rem]"
/>
</FormField>
<FormField
label="Default Model"
required
error={touched.projectModelKey && !formData.projectModelKey}
errorMessage="Default model is required"
>
<ObsidianNativeSelect
value={formData.projectModelKey}
onChange={(e) => {
const value = e.target.value;
const selectedModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === value
);
if (!selectedModel) return;
const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings);
if (!hasApiKey && errorNotice) {
new Notice(errorNotice);
return;
}
handleInputChange("projectModelKey", value);
}}
onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))}
placeholder="Select a model"
options={settings.activeModels
.filter((m) => m.enabled && m.projectEnabled)
.map((model) => ({
label: getModelDisplayWithIcons(model),
value: getModelKeyFromModel(model),
}))}
/>
</FormField>
<div className="space-y-4">
<div className="text-base font-medium">Model Configuration</div>
<div className="grid grid-cols-1 gap-4">
<FormField label="Temperature">
<SettingSlider
value={formData.modelConfigs?.temperature ?? 1}
onChange={(value) => handleInputChange("modelConfigs.temperature", value)}
min={0}
max={2}
step={0.01}
className="w-full"
/>
</FormField>
<FormField label="Token Limit">
<SettingSlider
value={formData.modelConfigs?.maxTokens ?? 1000}
onChange={(value) => handleInputChange("modelConfigs.maxTokens", value)}
min={1}
max={16000}
step={1}
className="w-full"
/>
</FormField>
</div>
</div>
<div className="space-y-4">
<div className="text-base font-medium">Context Sources</div>
<FormField
label="Inclusions"
description="Define patterns to include specific files or folders in the project context"
>
<div className="flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
{formData.contextSource?.inclusions?.trim()
? "Patterns configured"
: "No patterns configured"}
</div>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value: string) => {
handleInputChange("contextSource.inclusions", value);
},
formData.contextSource?.inclusions || "",
"Manage Inclusions"
).open()
}
>
Manage Patterns
</Button>
</div>
</FormField>
<FormField
label="Exclusions"
description="Exclude specific files or patterns from the included folders above"
>
<div className="flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
{formData.contextSource?.exclusions?.trim()
? "Patterns configured"
: "No patterns configured"}
</div>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value: string) => {
handleInputChange("contextSource.exclusions", value);
},
formData.contextSource?.exclusions || "",
"Manage Exclusions"
).open()
}
>
Manage Patterns
</Button>
</div>
</FormField>
<FormField label="Web URLs">
<Textarea
value={formData.contextSource?.webUrls}
onChange={(e) => {
const urls = e.target.value.split("\n");
// Process each URL while preserving spaces and empty lines
const processedUrls = urls.map((url) => {
if (!url.trim()) return url; // Preserve empty lines and whitespace-only lines
try {
new URL(url.trim());
return url; // Keep original formatting including spaces
} catch {
return url; // Keep invalid URLs for user to fix
}
});
handleInputChange("contextSource.webUrls", processedUrls.join("\n"));
}}
placeholder="Enter web URLs, one per line"
className="min-h-[80px] w-full"
/>
</FormField>
<FormField label="YouTube URLs">
<Textarea
value={formData.contextSource?.youtubeUrls}
onChange={(e) => {
const urls = e.target.value.split("\n");
// Process each URL while preserving spaces and empty lines
const processedUrls = urls.map((url) => {
if (!url.trim()) return url; // Preserve empty lines and whitespace-only lines
try {
const urlObj = new URL(url.trim());
if (
urlObj.hostname.includes("youtube.com") ||
urlObj.hostname.includes("youtu.be")
) {
return url; // Keep original formatting including spaces
}
return url; // Keep non-YouTube URLs for user to fix
} catch {
return url; // Keep invalid URLs for user to fix
}
});
handleInputChange("contextSource.youtubeUrls", processedUrls.join("\n"));
}}
placeholder="Enter YouTube URLs, one per line"
className="min-h-[80px] w-full"
/>
</FormField>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-4">
<Button variant="ghost" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSubmitting || !isFormValid()}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
);
}
export class AddProjectModal extends Modal {
private root: Root;
constructor(
app: App,
private onSave: (project: ProjectConfig) => Promise<void>,
private initialProject?: ProjectConfig
) {
super(app);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleSave = async (project: ProjectConfig) => {
await this.onSave(project);
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
/>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,15 @@
import { App } from "obsidian";
import { ConfirmModal } from "./ConfirmModal";
export class ApplyChangesConfirmModal extends ConfirmModal {
constructor(app: App, unDecidedChanges: number, onConfirm: () => void) {
super(
app,
onConfirm,
`There are ${unDecidedChanges} changes that have not been decided. Are you sure you want to skip these changes?`,
"Apply Changes",
"Confirm",
"Back to Edit"
);
}
}

View file

@ -6,16 +6,20 @@ function ConfirmModalContent({
content,
onConfirm,
onCancel,
confirmButtonText,
cancelButtonText,
}: {
content: string;
onConfirm: () => void;
onCancel: () => void;
confirmButtonText: string;
cancelButtonText: string;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>{content}</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<button onClick={onCancel}>Cancel</button>
<button onClick={onCancel}>{cancelButtonText}</button>
<button
style={{
backgroundColor: "var(--interactive-accent)",
@ -24,7 +28,7 @@ function ConfirmModalContent({
}}
onClick={onConfirm}
>
Continue
{confirmButtonText}
</button>
</div>
</div>
@ -38,7 +42,9 @@ export class ConfirmModal extends Modal {
app: App,
private onConfirm: () => void,
private content: string,
title: string
title: string,
private confirmButtonText: string = "Continue",
private cancelButtonText: string = "Cancel"
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
@ -64,6 +70,8 @@ export class ConfirmModal extends Modal {
content={this.content}
onConfirm={handleConfirm}
onCancel={handleCancel}
confirmButtonText={this.confirmButtonText}
cancelButtonText={this.cancelButtonText}
/>
);
}

View file

@ -1,7 +1,6 @@
import { useModelKey } from "@/aiParams";
import { processCommandPrompt } from "@/commands/inlineEditCommandUtils";
import { getModelDisplayText } from "@/components/ui/model-display";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { InlineEditCommandSettings, useSettingsValue } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { findCustomModel, insertIntoEditor } from "@/utils";
@ -9,6 +8,7 @@ import { Bot, Copy, PenLine } from "lucide-react";
import { App, Modal, Notice } from "obsidian";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import ProjectManager from "@/LLMProviders/projectManager";
interface InlineEditModalContentProps {
originalText: string;
@ -44,7 +44,9 @@ function InlineEditModalContent({
async function stream() {
const prompt = processCommandPrompt(command.prompt, originalText);
let fullAIResponse = "";
const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel);
const chatModel = await ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.createModelInstance(selectedModel);
const chatStream = await chatModel.stream(prompt);
for await (const chunk of chatStream) {
if (abortController?.signal.aborted) break;

View file

@ -5,16 +5,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"mod-cta bg-interactive-accent text-on-accent shadow hover:bg-interactive-accent-hover",
destructive: "bg-modifier-error text-normal shadow-sm hover:bg-modifier-error/90",
destructive:
"clickable-icon bg-modifier-error text-on-accent hover:bg-modifier-error hover:text-on-accent",
secondary: "bg-secondary text-normal shadow-sm hover:bg-interactive-hover",
ghost: "clickable-icon bg-transparent hover:bg-interactive-accent hover:text-on-accent",
link: "text-accent underline-offset-4 hover:underline",
success:
"clickable-icon bg-modifier-success text-on-accent hover:bg-modifier-success hover:text-on-accent",
ghost2:
"text-faint clickable-icon bg-transparent hover:bg-opacity-100 hover:text-normal hover:bg-transparent outline-none focus-visible:outline-none focus-visible:text-normal focus-visible:ring-0",
},

View file

@ -0,0 +1,59 @@
import React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
export interface SelectOption {
label: string;
value: string;
}
interface ObsidianNativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[];
placeholder?: string;
containerClassName?: string;
}
export function ObsidianNativeSelect({
options,
placeholder,
containerClassName,
className,
...props
}: ObsidianNativeSelectProps) {
return (
<div className={cn("relative w-full group", containerClassName)}>
<select
className={cn(
"w-full appearance-none",
"flex h-9 rounded-md border border-solid border-border bg-dropdown px-3 py-1 pr-8",
"text-sm !shadow transition-colors",
"focus:outline-none focus:ring-1 focus:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"hover:bg-interactive-hover hover:text-normal",
className
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2",
"transition-colors group-hover:[&>svg]:text-normal",
props.disabled && "opacity-50"
)}
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"!shadow-none",
"data-[state=active]:!bg-interactive-accent data-[state=active]:!text-on-accent data-[state=active]:!shadow-md",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-ring transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

41
src/composerUtils.ts Normal file
View file

@ -0,0 +1,41 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { getSystemPrompt } from "@/settings/model";
// Cache for the composer prompt
let cachedComposerPrompt: string | null = null;
/**
* Reset the cached composer prompt.
*/
export function resetComposerPromptCache(): void {
cachedComposerPrompt = null;
}
/**
* Get the system prompt for the composer.
* This combines the user's system prompt with the composer-specific prompt.
*/
export async function getComposerSystemPrompt(): Promise<string> {
// Get the current system prompt
const currentSystemPrompt = getSystemPrompt();
// If we already have a cached composer prompt, use it
if (cachedComposerPrompt) {
return `${currentSystemPrompt}\n${cachedComposerPrompt}`;
}
// Otherwise, fetch it from the API
const brevilabsClient = BrevilabsClient.getInstance();
try {
// Get the composer prompt from the API
const composerPromptResponse = await brevilabsClient.composerPrompt();
cachedComposerPrompt = composerPromptResponse.prompt;
// Combine the prompts
return `${currentSystemPrompt}\n${cachedComposerPrompt}`;
} catch (error) {
console.error("Failed to fetch composer prompt:", error);
// Fallback to just the system prompt if API call fails
return currentSystemPrompt;
}
}

View file

@ -50,6 +50,8 @@ export enum ChatModels {
AZURE_OPENAI = "azure-openai",
GEMINI_PRO = "gemini-2.0-pro-exp",
GEMINI_FLASH = "gemini-2.0-flash",
GEMINI_15_PRO = "gemini-1.5-pro-001",
GEMINI_15_FLASH = "gemini-1.5-flash-001", // TODO(logan): Project should use 2.0 flash once it supports context caching
CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest",
CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest",
COMMAND_R = "command-r",
@ -114,6 +116,7 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
enabled: true,
isBuiltIn: true,
core: true,
projectEnabled: true,
capabilities: [ModelCapability.VISION],
},
{
@ -170,6 +173,24 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [
isBuiltIn: true,
capabilities: [ModelCapability.VISION],
},
{
name: ChatModels.GEMINI_15_PRO,
provider: ChatModelProviders.GOOGLE,
enabled: true,
isBuiltIn: true,
core: true,
projectEnabled: true,
capabilities: [ModelCapability.VISION],
},
{
name: ChatModels.GEMINI_15_FLASH,
provider: ChatModelProviders.GOOGLE,
enabled: true,
isBuiltIn: true,
core: true,
projectEnabled: true,
capabilities: [ModelCapability.VISION],
},
{
name: ChatModels.AZURE_OPENAI,
provider: ChatModelProviders.AZURE_OPENAI,
@ -468,6 +489,18 @@ export const COMMAND_NAMES: Record<CommandId, string> = {
export type CommandId = (typeof COMMAND_IDS)[keyof typeof COMMAND_IDS];
export const EVENT_NAMES = {
CHAT_IS_VISIBLE: "chat-is-visible",
ACTIVE_LEAF_CHANGE: "active-leaf-change",
};
export const AUTOCOMPLETE_CONFIG = {
DELAY_MS: 300,
MIN_TRIGGER_LENGTH: 3,
MAX_CONTEXT_LENGTH: 10000,
KEYBIND: "Tab",
} as const;
export const DEFAULT_SETTINGS: CopilotSettings = {
isPlusUser: false,
plusLicenseKey: "",
@ -521,11 +554,8 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
promptUsageTimestamps: {},
defaultConversationNoteName: "{$topic}@{$date}_{$time}",
inlineEditCommands: DEFAULT_INLINE_EDIT_COMMANDS,
};
export const EVENT_NAMES = {
CHAT_IS_VISIBLE: "chat-is-visible",
ACTIVE_LEAF_CHANGE: "active-leaf-change",
projectList: [],
enableAutocomplete: true,
};
export enum ABORT_REASON {

142
src/hooks/useApplyCode.ts Normal file
View file

@ -0,0 +1,142 @@
import { App, Notice, TFile } from "obsidian";
import { useCallback } from "react";
import { BrevilabsClient, ComposerApplyRequest } from "@/LLMProviders/brevilabsClient";
import { ChatMessage } from "@/sharedState";
import { USER_SENDER } from "@/constants";
import { APPLY_VIEW_TYPE } from "@/components/composer/ApplyView";
import { logError, logInfo } from "@/logger";
/**
* Custom hook for applying code to a file using the composer API
* @param app The Obsidian app instance
* @param chatHistory The chat history to provide context for the code application
* @returns A callback function to apply code to a file
*/
export function useApplyCode(app: App, chatHistory: ChatMessage[] = []) {
return useCallback(
async (path: string, code: string) => {
try {
// Get the file from the path
let file = app.vault.getAbstractFileByPath(path);
let isNewFile = false;
// If file doesn't exist, create it
if (!file) {
try {
// Create the file with the provided code as content
await app.vault.create(path, code);
new Notice(`Created new file: ${path}`);
// Get the newly created file
file = app.vault.getAbstractFileByPath(path);
isNewFile = true;
} catch (createError) {
logError("Error creating file:", createError);
new Notice(`Failed to create file: ${createError.message}`);
return;
}
}
if (!(file instanceof TFile)) {
new Notice(`Path is not a file: ${path}`);
return;
}
// Get the original content
const originalContent = await app.vault.read(file);
// Check if the current active note is the same as the target note
const activeFile = app.workspace.getActiveFile();
if (!activeFile || activeFile.path !== file.path) {
// If not, open the target file in the current leaf
await app.workspace.getLeaf().openFile(file);
new Notice(`Switched to ${file.name}`);
}
// If the file is newly created, don't show the apply view
if (isNewFile) {
return;
}
// Generate new content and show diff in the apply view.
try {
// Call the composer apply endpoint
const brevilabsClient = BrevilabsClient.getInstance();
// Convert chat history to the format expected by the API
const formattedChatHistory = chatHistory
.filter((msg) => msg.isVisible)
.map((msg) => ({
role: msg.sender === USER_SENDER ? "user" : "assistant",
content: msg.message,
}));
// Create the request object
const request: ComposerApplyRequest = {
target_note: {
title: file.basename,
content: originalContent,
},
chat_history: formattedChatHistory,
markdown_block: code,
};
// Call the composer apply endpoint
logInfo("==== Composer Request ====\n", request);
const response = await brevilabsClient.composerApply(request);
// Use the content from the response
let newContent = response.content;
// Remove the code block markers if they exist
//TODO: Remove this once the issue is fixed in the backend
if (newContent.startsWith("```\n") && newContent.endsWith("\n```")) {
newContent = newContent.slice(4, -4);
}
if (newContent.startsWith("```markdown\n") && newContent.endsWith("\n```")) {
newContent = newContent.slice(12, -4);
}
// Remove trailing newline from newContent if originalContent doesn't end with one
//TODO: Remove this once the issue is fixed in the backend
if (!originalContent.endsWith("\n") && newContent.endsWith("\n")) {
newContent = newContent.slice(0, -1);
}
// Open the Apply View in a new leaf with the processed content
const leaf = app.workspace.getLeaf(true);
await leaf.setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file,
originalContent,
newContent,
path,
},
});
} catch (error) {
logError("Error calling composer apply:", error);
new Notice(`Error processing code: ${error.message}`);
// Fallback to original behavior if composer apply fails
const leaf = app.workspace.getLeaf(true);
await leaf.setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: file,
originalContent: originalContent,
newContent: code,
path: path,
},
});
}
} catch (error) {
logError("Error applying code:", error);
new Notice(`Error applying code: ${error.message}`);
}
},
[app, chatHistory]
);
}

View file

@ -1,9 +1,11 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChainManager from "@/LLMProviders/chainManager";
import ProjectManager from "@/LLMProviders/projectManager";
import { CustomModel } from "@/aiParams";
import { AutocompleteService } from "@/autocomplete/autocompleteService";
import { parseChatContent, updateChatMemory } from "@/chatUtils";
import { registerCommands } from "@/commands";
import CopilotView from "@/components/CopilotView";
import { ApplyView, APPLY_VIEW_TYPE } from "@/components/composer/ApplyView";
import { LoadChatHistoryModal } from "@/components/modals/LoadChatHistoryModal";
import { CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/constants";
import { registerContextMenu } from "@/contextMenu";
@ -32,17 +34,19 @@ import {
WorkspaceLeaf,
} from "obsidian";
import { IntentAnalyzer } from "./LLMProviders/intentAnalyzer";
import { logInfo } from "@/logger";
export default class CopilotPlugin extends Plugin {
// A chat history that stores the messages sent and received
// Only reset when the user explicitly clicks "New Chat"
sharedState: SharedState;
chainManager: ChainManager;
projectManager: ProjectManager;
brevilabsClient: BrevilabsClient;
userMessageHistory: string[] = [];
vectorStoreManager: VectorStoreManager;
fileParserManager: FileParserManager;
settingsUnsubscriber?: () => void;
private autocompleteService: AutocompleteService;
async onload(): Promise<void> {
await this.loadSettings();
@ -55,8 +59,9 @@ export default class CopilotPlugin extends Plugin {
registerCommands(this, prev, next);
});
this.addSettingTab(new CopilotSettingTab(this.app, this));
// Always have one instance of sharedState and chainManager in the plugin
this.sharedState = new SharedState();
// Always have one instance of sharedState in the plugin
this.sharedState = new SharedState(this);
this.vectorStoreManager = VectorStoreManager.getInstance();
@ -65,12 +70,14 @@ export default class CopilotPlugin extends Plugin {
this.brevilabsClient.setPluginVersion(this.manifest.version);
checkIsPlusUser();
this.chainManager = new ChainManager(this.app, this.vectorStoreManager);
// Initialize ProjectManager
this.projectManager = ProjectManager.getInstance(this.app, this.vectorStoreManager, this);
// Initialize FileParserManager early with other core services
this.fileParserManager = new FileParserManager(this.brevilabsClient);
this.registerView(CHAT_VIEWTYPE, (leaf: WorkspaceLeaf) => new CopilotView(leaf, this));
this.registerView(APPLY_VIEW_TYPE, (leaf: WorkspaceLeaf) => new ApplyView(leaf));
this.initActiveLeafChangeHandler();
@ -108,6 +115,9 @@ export default class CopilotPlugin extends Plugin {
}
})
);
// Initialize autocomplete service
this.autocompleteService = AutocompleteService.getInstance(this);
}
async onunload() {
@ -115,9 +125,11 @@ export default class CopilotPlugin extends Plugin {
if (this.vectorStoreManager) {
this.vectorStoreManager.onunload();
}
this.settingsUnsubscriber?.();
console.log("Copilot plugin unloaded");
this.settingsUnsubscriber?.();
this.autocompleteService?.destroy();
logInfo("Copilot plugin unloaded");
}
updateUserMessageHistory(newMessage: string) {
@ -300,7 +312,7 @@ export default class CopilotPlugin extends Plugin {
messages.forEach((message) => this.sharedState.addMessage(message));
// Update the chain's memory with the loaded messages
await updateChatMemory(messages, this.chainManager.memoryManager);
await updateChatMemory(messages, this.projectManager.getCurrentChainManager().memoryManager);
// Check if the Copilot view is already active
const existingView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0];

View file

@ -1,5 +1,4 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import EmbeddingManager from "@/LLMProviders/embeddingManager";
import { logInfo } from "@/logger";
import VectorStoreManager from "@/search/vectorStoreManager";
@ -13,6 +12,7 @@ import { BaseRetriever } from "@langchain/core/retrievers";
import { search } from "@orama/orama";
import { TFile } from "obsidian";
import { DBOperations } from "./dbOperations";
import ProjectManager from "@/LLMProviders/projectManager";
export class HybridRetriever extends BaseRetriever {
public lc_namespace = ["hybrid_retriever"];
@ -130,8 +130,9 @@ export class HybridRetriever extends BaseRetriever {
private async rewriteQuery(query: string): Promise<string> {
try {
const promptResult = await this.queryRewritePrompt.format({ question: query });
const chatModel = ChatModelManager.getInstance()
.getChatModel()
const chatModel = ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModel()
.bind({ temperature: 0 } as BaseChatModelCallOptions);
const rewrittenQueryObject = await chatModel.invoke(promptResult);

View file

@ -94,18 +94,35 @@ function getInclusionPatterns(): string[] {
}
/**
* Get the inclusion and exclusion patterns from the settings.
* Get the inclusion and exclusion patterns from the settings or provided values.
* NOTE: isProject is used to determine if the patterns should be used for a project, ignoring global inclusions and exclusions
* @param options - Optional parameters for inclusions and exclusions.
* @returns An object containing the inclusions and exclusions patterns strings.
*/
export function getMatchingPatterns(): {
export function getMatchingPatterns(options?: {
inclusions?: string;
exclusions?: string;
isProject?: boolean;
}): {
inclusions: PatternCategory | null;
exclusions: PatternCategory | null;
} {
const inclusions = getInclusionPatterns();
const exclusions = getExclusionPatterns();
// For projects, don't fall back to global patterns
const inclusionPatterns = options?.inclusions
? getDecodedPatterns(options.inclusions)
: options?.isProject
? []
: getInclusionPatterns();
const exclusionPatterns = options?.exclusions
? getDecodedPatterns(options.exclusions)
: options?.isProject
? []
: getExclusionPatterns();
return {
inclusions: inclusions.length > 0 ? categorizePatterns(inclusions) : null,
exclusions: exclusions.length > 0 ? categorizePatterns(exclusions) : null,
inclusions: inclusionPatterns.length > 0 ? categorizePatterns(inclusionPatterns) : null,
exclusions: exclusionPatterns.length > 0 ? categorizePatterns(exclusionPatterns) : null,
};
}

View file

@ -1,4 +1,4 @@
import { CustomModel } from "@/aiParams";
import { CustomModel, ProjectConfig } from "@/aiParams";
import { atom, createStore, useAtomValue } from "jotai";
import { type ChainType } from "@/chainFactory";
@ -90,6 +90,9 @@ export interface CopilotSettings {
// undefined means never checked
isPlusUser: boolean | undefined;
inlineEditCommands: InlineEditCommandSettings[] | undefined;
// Autocomplete settings
enableAutocomplete: boolean;
projectList: Array<ProjectConfig>;
}
export const settingsStore = createStore();

View file

@ -1,4 +1,3 @@
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { CustomModel } from "@/aiParams";
import { Button } from "@/components/ui/button";
import {
@ -15,6 +14,7 @@ import { err2String, getNeedSetKeyProvider, getProviderInfo, getProviderLabel }
import { Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
import ProjectManager from "@/LLMProviders/projectManager";
interface ApiKeyDialogProps {
open: boolean;
@ -96,7 +96,7 @@ const ApiKeyDialog: React.FC<ApiKeyDialogProps> = ({
apiKey,
enabled: true,
};
await ChatModelManager.getInstance().ping(customModel);
await ProjectManager.instance.getCurrentChainManager().chatModelManager.ping(customModel);
new Notice("API key verified successfully!");
setUnverifiedKeys((prev) => {

View file

@ -2,25 +2,26 @@ import { ChainType } from "@/chainFactory";
import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getModelDisplayWithIcons } from "@/components/ui/model-display";
import { SettingItem } from "@/components/ui/setting-item";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DEFAULT_OPEN_AREA, PLUS_UTM_MEDIUMS } from "@/constants";
import { useTab } from "@/contexts/TabContext";
import { createPlusPageUrl } from "@/plusUtils";
import VectorStoreManager from "@/search/vectorStoreManager";
import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/settings/model";
import { formatDateTime, checkModelApiKey } from "@/utils";
import { PlusSettings } from "@/settings/v2/components/PlusSettings";
import { checkModelApiKey, formatDateTime } from "@/utils";
import { HelpCircle, Key, Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
import ApiKeyDialog from "./ApiKeyDialog";
import { PlusSettings } from "@/settings/v2/components/PlusSettings";
import { TooltipProvider, Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { createPlusPageUrl } from "@/plusUtils";
import { getModelDisplayWithIcons } from "@/components/ui/model-display";
import VectorStoreManager from "@/search/vectorStoreManager";
const ChainType2Label: Record<ChainType, string> = {
[ChainType.LLM_CHAIN]: "Chat",
[ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)",
[ChainType.COPILOT_PLUS_CHAIN]: "Copilot Plus (beta)",
[ChainType.PROJECT_CHAIN]: "Plus Projects (alpha)",
};
export const BasicSettings: React.FC = () => {
@ -400,6 +401,34 @@ export const BasicSettings: React.FC = () => {
checked={settings.showRelevantNotes}
onCheckedChange={(checked) => updateSetting("showRelevantNotes", checked)}
/>
{/* Autocomplete Toggle */}
<SettingItem
type="switch"
title="Enable Autocomplete"
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">
Enable AI-powered autocomplete suggestions while typing
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent className="max-w-96 flex flex-col gap-2">
<div className="text-sm text-muted pt-2">
When enabled, you&apos;ll get AI-powered suggestions as you type. Press Tab
to accept a suggestion.
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
checked={settings.enableAutocomplete}
onCheckedChange={(checked) => updateSetting("enableAutocomplete", checked)}
/>
</div>
</section>
</div>

View file

@ -2,11 +2,11 @@ import React, { useState } from "react";
import { SettingItem } from "@/components/ui/setting-item";
import { setSettings, updateSetting, useSettingsValue } from "@/settings/model";
import { CustomModel } from "@/aiParams";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import EmbeddingManager from "@/LLMProviders/embeddingManager";
import { ModelAddDialog } from "@/settings/v2/components/ModelAddDialog";
import { ModelTable } from "@/settings/v2/components/ModelTable";
import { ModelEditDialog } from "@/settings/v2/components/ModelEditDialog";
import ProjectManager from "@/LLMProviders/projectManager";
export const ModelSettings: React.FC = () => {
const settings = useSettingsValue();
@ -94,7 +94,9 @@ export const ModelSettings: React.FC = () => {
const updatedModels = [...settings.activeModels, model];
updateSetting("activeModels", updatedModels);
}}
ping={(model) => ChatModelManager.getInstance().ping(model)}
ping={(model) =>
ProjectManager.instance.getCurrentChainManager().chatModelManager.ping(model)
}
/>
<div className="space-y-4">

View file

@ -153,7 +153,7 @@ const ModelCard: React.FC<ModelCardProps> = ({ model, onEdit, onDelete, onUpdate
"border-accent/50",
"relative",
"z-[9999]",
"bg-background",
"bg-primary",
"rounded-lg",
"transform-gpu",
],
@ -284,7 +284,7 @@ const SortableTableRow: React.FC<{
className={cn(
"hover:bg-interactive-accent/10 transition-colors duration-200",
isDragging &&
"shadow-lg bg-background/90 backdrop-blur-sm relative z-[100] cursor-grabbing border-2 border-accent/50",
"shadow-lg bg-primary/90 backdrop-blur-sm relative z-[100] cursor-grabbing border-2 border-accent/50",
!isDragging && "z-auto"
)}
>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { FormattedDateTime } from "./utils";
import { TFile } from "obsidian";
import CopilotPlugin from "@/main";
export interface ChatMessage {
message: string;
@ -18,10 +19,14 @@ export interface ChatMessage {
}
class SharedState {
constructor(private plugin: CopilotPlugin) {
this.plugin = plugin;
}
chatHistory: ChatMessage[] = [];
addMessage(message: ChatMessage): void {
this.chatHistory.push(message);
this.plugin.projectManager.getCurrentChainManager().addChatMessage(message);
}
getMessages(): ChatMessage[] {
@ -30,6 +35,11 @@ class SharedState {
clearChatHistory(): void {
this.chatHistory = [];
this.plugin.projectManager.getCurrentChainManager().clearHistory();
}
replaceMessages(messages: ChatMessage[]): void {
this.chatHistory = [...messages];
}
}
@ -48,8 +58,8 @@ export function useSharedState(
// When there are no dependencies, the effect will only run once,
// *right after the initial render* (similar to componentDidMount in class components).
useEffect(() => {
setChatHistory(sharedState.getMessages());
}, [sharedState]);
setChatHistory([...sharedState.getMessages()]);
}, [sharedState, sharedState.chatHistory]);
const addMessage = useCallback(
(message: ChatMessage) => {

View file

@ -90,16 +90,6 @@ If your plugin does not need CSS, delete this file.
overflow: hidden;
}
.bottom-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
width: 100%;
flex: 0 0 auto;
box-sizing: border-box;
}
.chat-context-menu {
display: flex;
align-items: center;

View file

@ -1053,3 +1053,7 @@ export function checkModelApiKey(
export function removeThinkTags(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
export function randomUUID() {
return crypto.randomUUID();
}

View file

@ -43,11 +43,12 @@ module.exports = {
inherit: colors.inherit,
current: colors.current,
transparent: colors.transparent,
background: "var(--background-primary)",
primary: "var(--background-primary)",
"primary-alt": "var(--background-primary-alt)",
secondary: "var(--background-secondary)",
"secondary-alt": "var(--background-secondary-alt)",
success: "rgba(var(--color-green-rgb),0.2)",
error: "rgba(var(--color-red-rgb),0.2)",
modifier: {
hover: "var(--background-modifier-hover)",
"active-hover": "var(--background-modifier-active-hover)",