This commit is contained in:
duanfuxiang 2025-01-05 11:51:39 +08:00
commit 0c7ee142cb
215 changed files with 20611 additions and 0 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 2
tab_width = 2

55
.eslintignore Normal file
View File

@ -0,0 +1,55 @@
# compiled output
main.js
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# local env files
.env
.env*.local
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
esbuild.config.mjs
jest.config.js
version-bump.mjs
versions.json
package.json
manifest.json
compile-migration.js
import-meta-url-shim.js
# Obsidian
.obsidian
# Markdown
*.md

66
.eslintrc.js Normal file
View File

@ -0,0 +1,66 @@
/** @type {import("eslint").Linter.Config} */
const config = {
root: true,
env: {
node: true,
jest: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict',
'plugin:import/recommended',
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
'@typescript-eslint/no-extraneous-class': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-useless-constructor': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-unresolved': 'off',
'sort-imports': [
'error',
{
ignoreCase: false,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
allowSeparatedGroups: true,
},
],
'import/order': [
'error',
{
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
ignorePatterns: ['.eslintrc.js'],
}
module.exports = config

23
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- name: Type check
run: npm run type:check
- name: Lint check
run: npm run lint:check
- name: Test
run: npm test

34
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Release Obsidian plugin
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View File

@ -0,0 +1 @@
tag-version-prefix=""

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20.13

53
.prettierignore Normal file
View File

@ -0,0 +1,53 @@
# compiled output
main.js
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# local env files
.env
.env*.local
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
esbuild.config.mjs
jest.config.js
version-bump.mjs
versions.json
package.json
manifest.json
# Obsidian
.obsidian
# Markdown
*.md

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Heesu Suh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
README.md Normal file
View File

@ -0,0 +1,56 @@
<h1 align="center">Infio-Copilot</h1>
<p align="center">
<a href="">Documentation</a>
·
<a href="">Report Bug</a>
·
<a href="">Discussions</a>
</p>
**Infio-Copilot is a Cursor-inspired AI assistant for Obsidian that offers smart autocomplete and interactive chat with your selected notes**
## Features
### Chat & Edit Flow
Get instant AI assistance and apply suggested improvements with a single click, all within Obsidian
### Autocomplete
Receive context-aware writing suggestions as you type
### Inline Editing
Edit your notes directly within the current file
## Getting Started
> **⚠️ Important: Installer Version Requirement**Infio-Copilot requires a recent version of the Obsidian installer. If you experience issues with the plugin not loading properly:
>
> 1. First, try updating Obsidian normally at `Settings > General > Check for updates`.
> 2. If issues persist, manually update your Obsidian installer:
>
> - Download the latest installer from [Obsidian&#39;s download page](https://obsidian.md/download)
> - Close Obsidian completely
> - Run the new installer
1. Open Obsidian Settings
2. Navigate to "Community plugins" and click "Browse"
3. Search for "Infio Copilot" and click Install
4. Enable the plugin in Community plugins
5. Set up your API key in plugin settings
- DeepSeek : [DeepSeek API Keys](https://platform.deepseek.com/api_keys/)
- OpenAI : [ChatGPT API Keys](https://platform.openai.com/api-keys)
- Anthropic : [Claude API Keys](https://console.anthropic.com/settings/keys)
- Gemini : [Gemini API Keys](https://aistudio.google.com/apikey)
- Groq : [Groq API Keys](https://console.groq.com/keys)
## Feedback and Support
We value your input and want to ensure you can easily share your thoughts and report any issues:
- **Bug Reports**: If you encounter any bugs or unexpected behavior, please submit an issue on our [GitHub Issues](https://github.com/infiolab/infio-copilot/issues) page. Be sure to include as much detail as possible to help us reproduce and address the problem.
- **Feature Requests**: For new feature ideas or enhancements, please use our [GitHub Discussions - Ideas &amp; Feature Requests](https://github.com/infiolab/infio-copilot/discussions/categories/ideas) page. Create a new discussion to share your suggestions.
## License
This project is licensed under the [MIT License](LICENSE).

6
__mocks__/obsidian.ts Normal file
View File

@ -0,0 +1,6 @@
export const App = jest.fn()
export const Editor = jest.fn()
export const MarkdownView = jest.fn()
export const TFile = jest.fn()
export const TFolder = jest.fn()
export const Vault = jest.fn()

View File

@ -0,0 +1,4 @@
# A Tale of Two Cities
The most famous quote from this book is:
>

View File

@ -0,0 +1,21 @@
## Kadane's algorithm
Kadane's algorithm is an $O(n)$ solution to this problem.
The key idea is that for each element, we have only 2 choices:
- Add this element to the current subset.
- Start a new subset.
Due to this reasons, the problem can a viewed as a sliding window problem.
The general steps are as follows:
1. Initialize two variables, say `currentSum` and `maxSum`. Set both of them to the first element of the array.
2. Loop through from the 2nd to the last element in the array.
1. Decide to include the current number or to start a new subset using: `currentSum = max(num, currentSum + num)`
2. If the current value of the subset is the best so far update `maxSum`
```python
```

View File

@ -0,0 +1,21 @@
## Kadane's algorithm
Kadane's algorithm is an $O(n)$ solution to this problem.
The key idea is that for each element, we have only 2 choices:
- Add this element to the current subset.
- Start a new subset.
Due to this reasons, the problem can a viewed as a sliding window problem.
The general steps are as follows:
1. Initialize two variables, say `currentSum` and `maxSum`. Set both of them to the first element of the array.
2. Loop through from the 2nd to the last element in the array.
1. Decide to include the current number or to start a new subset using: `currentSum = max(num, currentSum + num)`
2. If the current value of the subset is the best so far update `maxSum`
```rust
```

View File

@ -0,0 +1,22 @@
## Kadane's algorithm
Kadane's algorithm is an $O(n)$ solution to this problem.
The key idea is that for each element, we have only 2 choices:
- Add this element to the current subset.
- Start a new subset.
Due to this reasons, the problem can a viewed as a sliding window problem.
The general steps are as follows:
1. Initialize two variables, say `currentSum` and `maxSum`. Set both of them to the first element of the array.
2. Loop through from the 2nd to the last element in the array.
1. Decide to include the current number or to start a new subset using: `currentSum = max(num, currentSum + num)`
2. If the current value of the subset is the best so far update `maxSum`
```typescript
```

View File

@ -0,0 +1,9 @@
# Fizz buzz
Fizz buzz is often used as an interview problem to test if an applicant has basic programming knowledge. For a give `n`, the program should print the number between `1` and `n`. If the i-th number is divisible by three, it is replaced by `fizz`. If the number is divisible by five, it should print `buzz`. If the number is divisible by both, it should print `fizz buzz`.
In Javascript, you can implement it as follows:
```javascript
function fizzBuzz(n) {
}
```

View File

@ -0,0 +1,7 @@
# SOLID design principles
SOLID is a set of design principles from  Robert C. Martin. It consists of the following sub-principles:
- S: single responsibility principle
- O: open closed principle
-
- D: Dependency inversion

View File

@ -0,0 +1,7 @@
# Dead ReLU problem
A neuron is considered dead if it does not activate for any of the training instance in the training dataset. Because it never activates it will never have a gradient due to the chain rule so it also cannot change anymore. The dead ReLU problem can have due to a wide variety of reasons, such as:
-
This can be computational wasteful, since we still need to do the matrix multiplication, while it will never have an impact on the activations or gradients. It also reduces the learning capacity of the network, since it has to learn the same function with fewer neurons.
The gradient $\frac{dy}{dx} = 0$ if $x < 0$. This is no problem if it happens for some instance but it is a problem if it happens for all instances.

View File

@ -0,0 +1,24 @@
# Softmax
The softmax function transforms a vector into a probability distribution such that the sum of the vector is equal to 1.
$$
$$
## Numerical stability improvements
### Rescaling exponent
Due to the exponent operation, it is very likely that you get $\infty$ values.
You can prevent this by ensuring that the largest possible exponent is $0$.
This is typically implement by finding the maximum value and then subtracting it:
```python
def softmax(x, dim=-1):
c = x.max()
return torch.exp(x - c) / torch.exp(x - c).sum(dim=dim, keepdim=True)
```
## Calculating the log of the softmax
Calculating the log of the softmax can be numerically unstable so it is better to use the log-softmax approach.

View File

@ -0,0 +1,5 @@
# Create random pillow image
You can get a random color by randomly selecting a red, green and blue value. We can use these color values to generate a random Pillow image as follows:
```python
```

View File

@ -0,0 +1,8 @@
# Fizz buzz
Fizz buzz is often used as an interview problem to test if an applicant has basic programming knowledge. For a give `n`, the program should print the number between `1` and `n`. If the i-th number is divisible by three, it is replaced by `fizz`. If the number is divisible by five, it should print `buzz`. If the number is divisible by both, it should print `fizz buzz`.
In Python, you can implement it as follows:
```python
def fizz_buzz(n: int) -> None:
```

22
demo_vault/README.md Normal file
View File

@ -0,0 +1,22 @@
# Demo vault Readme
This is a demo Obsidian vault for testing the plugin.
The command `npm run dev` will ensure the plugin artifacts are symlinked to correct locations allowing you to test the plugin in this vault.
The file contains some example notes with useful content for testing the plugin.
The following files are used to test certain completion scenarios:
- `Block quote (a tale of two cities).md`: contains an example note with a block quote. The plugin should be able to complete the block quote.
- `Code generation (Kadane algorithm python).md`: contains an example note about the Kadane algorithm. The Python code is incomplete. The plugin should be able to complete the code.
- `Code generation (Kadane algorithm rust).md`: contains an example note about the Kadane algorithm. The Rust code is incomplete. The plugin should be able to complete the code.
- `Code generation (Kadane algorithm typescript).md`: contains an example note about the Kadane algorithm. The Typescript code is incomplete. The plugin should be able to complete the code.
- `Javascript code completion test (fizz buzz).md`: contains an example note about the fizz buzz problem. The Javascript code is incomplete. The plugin should be able to complete the code.
- `List completion in the middle (missing solid principles).md`: contains an example note about the SOLID design principles. The list of principles is incomplete. The plugin should be able to complete the list.
- `List completion test (dead relu reasons).md`: contains an example note about the dead ReLU problem. The possible reasons for the dead ReLU problem are missing. The plugin should be able to complete the list of reasons.
- `Math block test (softmax math function).md`: contains an example note about the softmax function. The latex code is for the softmax function is missing. The plugin should be able to complete the latex code based on the notes description and Python code.
- `Python code completion test ( random pillow image).md`: contains an example note about the random pillow image. The Python code is incomplete. The plugin should be able to complete the code.
- `Python code completion test (fizz buzz).md`: contains an example note about the fizz buzz problem. The Python code is incomplete. The plugin should be able to complete the code.
- `task completion test (sub tasks todo list new york).md`: contains an example note about packing a todo list for a trip to New York. The todo list is incomplete. The plugin should be able to complete the todo list.
- `task completion test (tasks todo list new york).md`: contains an example note about packing a todo list for a trip to New York. The todo list is incomplete. The plugin should be able to complete the todo list.
- `Text completion test (Discreet Fourier transform).md`: contains an example note about the Discreet Fourier transform. The latex code for the Discreet Fourier transform is incomplete. The plugin should be able to complete the latex code based on the notes description and Python code.
- `Text completion test (git sha hash checksums).md`: contains an example note about git sha hash. The plugin should be able to complete the paragraph about git sha hash.
- `Title test (adapter design pattern).md`: contains an example note about the adapter design pattern. The plugin should be able to complete the title.
- `Title test (dutch bitcoin).md`: contains an example note about the Dutch bitcoin. The plugin should be able to complete the title in Dutch.

View File

@ -0,0 +1,33 @@
# Discreet Fourier transform
$$
\begin{bmatrix}
\hat{f_1} \\
\hat{f_2} \\
\hat{f_3} \\
... \\
\hat{f_n}
\end{bmatrix}
=
\begin{bmatrix}
1 & 1 & 1 & ... & 1\\
1 & w_n & w_n^2 & ... & w_n^{n-1}\\
1 & w^2_n & w_n^4 & ... & w_n^{2(n-1)}\\
... & ... & ... & ... & ...\\
1 & w_n^{n-1} & w_n^{2(n-1)} & ... & w_n^{(n-1)^2}
\end{bmatrix}
\begin{bmatrix}
f_1 \\
f_2 \\
f_3 \\
... \\
f_n
\end{bmatrix}
$$
$$
w_n = e^{-2j \pi / n}
$$

View File

@ -0,0 +1,12 @@
# Git sha hash checksums
Every snapshot in git is check summed before it is stored using an SHA-1. This produces a 40-char string with hexadecimal chars (0-9 & a-f).
This hash is based on the following information:
- The source tree of the commit (content of all the files in the repo and their locations).
- The parent commit sha1
- The author info
- The committer info (right, those are different!)
- The commit message

View File

@ -0,0 +1,3 @@
#
比特币是一种新型的数字货币。 它与其他类型的数字货币不同,因为它的运作没有中央机构,也没有银行等中介机构。 相反,它是一个开放且去中心化的网络,由用户控制。 该网络遍布全球,任何人都可以参与。 由于底层技术的原因,比特币的工作原理与大多数人习惯的不同。 要完全理解它,您需要学习一段时间。 但有多少人了解互联网背后的技术是如何运作的? 最重要的是你可以使用它:你不必成为专家就可以安全地存储或发送比特币。 对于那些想了解更多信息的人,我们在下面编写了技术说明。 虽然不足以称自己为比特币专家,但也许足以在酒吧里留下深刻的印象. 你不必成为专家就可以安全地存储或发送比特币。

View File

@ -0,0 +1,52 @@
#
The adapter pattern allows the interface of an existing class/function to be *adapted* into another interface. This approach makes it possible to make existing classes/function work with others without modify their of their source code. It works as follows:
1. The adapter wraps an existing class A.
2. The existing source code can now safely use the object of class A because it implement the expected interface.
3. Upon receiving a call, the adapter translate the call into the expected format for class A.
4. Class A does it computation and returns it results.
5. The adapter translates the returned value back into the expected format by the interface.
It sounds much more complicate than you think. All it does is create a wrapper that changes the interface to a more convenient signature using some translation steps.
```mermaid
classDiagram
class client
class ClientInterface
<<interface>> ClientInterface
class Adapter
Adapter : - service
Adapter : + method(data)
class Service
Service : ...
Service : + noCompatableMethod(specialData)
client --> ClientInterface: expects
Adapter ..> ClientInterface : implements
Adapter --> Service : wraps
```
## Application
- When you need to change an existing class signature but you can not or do not want to change the existing code.
- When you want to use a 3rd-party class that doesn't implement your expected interface. E.g. when you want to use beautiful soup read XML.
## Advantages
- single responsibility principle: The adapter is only responsible the translation part. The actual functionality is delegated to the class it wraps.
- open closed principle: You can introduce as many adapter as you want without breaking or changing any of the existing code.
## Disadvantage
- It adds an additional layer of abstraction, which increase the complexity of your code. Sometimes it is just simpler to change the signature of the existing code.
## Relationships to other patterns
- The adapter pattern changes the interface, while the decorator pattern and the proxy pattern do not.
- Recursive composition is not possible with the adapter pattern, while it is possible with the decorator pattern.
- The adapter pattern implements an existing interface, while the facade pattern implements a new (simpler interface).
- The adapter pattern acts as a translator, while the bridge pattern combines existing code to create something new.

View File

@ -0,0 +1,6 @@
#
Bitcoin is een nieuw soort digitaal geld. Het verschilt van andere soorten digitaal geld, omdat het werkt zonder centrale autoriteit en zonder tussenpartijen zoals banken. In plaats daarvan is het een open en decentraal netwerk, waarbij gebruikers zelf alle touwtjes in handen hebben. Het netwerk is verspreid over de hele wereld en iedereen kan meedoen.
Bitcoin werkt vanwege onderliggende technologie anders dan de meeste mensen gewend zijn. Om het helemaal te begrijpen zal je het een tijd moeten bestuderen. Maar, hoeveel mensen begrijpen hoe de techniek achter het internet werkt? Het belangrijkste is dat je het kunt gebruiken: je hoeft geen expert te zijn om bitcoins veilig te bewaren of te versturen.
Voor wie toch net iets meer wil weten hebben wij onderstaande technische uitleg geschreven. Niet genoeg om jezelf bitcoinexpert te noemen, maar misschien wel om indruk mee te maken in de kroeg.

View File

@ -0,0 +1,6 @@
#
Bitcoin is a new kind of digital money. It differs from other types of digital money because it works without a central authority and without intermediaries such as banks. Instead, it is an open and decentralized network, where users are in control. The network is spread all over the world and anyone can participate.
Bitcoin works differently than most people are used to due to the underlying technology. To fully understand it you will have to study it for a while. But how many people understand how the technology behind the internet works? The most important thing is that you can use it: you don't have to be an expert to store or send bitcoins safely.
For those who want to know a little more, we have written the technical explanation below. Not enough to call yourself a bitcoin expert, but perhaps enough to make an impression in the pub.

View File

@ -0,0 +1,8 @@
# Packing ToDo list for my trip to New York
For this trip I still need to do the following:
- [ ] Pack clothes and shoes
- [ ] Check weather forecast
- [ ] Prepare travel documents
- [ ] Arrange pet care for my cat Fluffy
- [ ] Pack pet supplies
- [ ]

View File

@ -0,0 +1,6 @@
# Packing ToDo list for my trip to New York
For this trip I still need to do the following:
- [ ] Pack clothes and shoes
- [ ] Check weather forecast
- [ ]
- [ ] Prepare travel documents

57
esbuild.config.mjs Normal file
View File

@ -0,0 +1,57 @@
import path from 'path'
import esbuild from 'esbuild'
import process from 'process'
import builtins from 'builtin-modules'
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`
const prod = process.argv[2] === 'production'
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
'@lexical/clipboard/clipboard',
...builtins,
],
format: 'cjs',
define: {
'import.meta.url': 'import_meta_url',
process: '{}',
'process.env.NODE_ENV': JSON.stringify(prod ? 'production' : 'development'),
},
inject: [path.resolve('import-meta-url-shim.js')],
target: 'es2020',
logLevel: 'info', // 'debug' for more detailed output
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
minify: prod,
})
if (prod) {
await context.rebuild()
process.exit(0)
} else {
await context.watch()
}

7
import-meta-url-shim.js Normal file
View File

@ -0,0 +1,7 @@
const import_meta_url =
typeof document === 'undefined'
? require('url').pathToFileURL(__filename).href
: (document.currentScript && document.currentScript.src) ||
new URL('main.js', document.baseURI).href
export { import_meta_url }

7
jest.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: 'node',
transform: {
'^.+.tsx?$': ['ts-jest', {}],
},
}

11
manifest.json Normal file
View File

@ -0,0 +1,11 @@
{
"id": "infio-copilot",
"name": "Infio Copilot",
"version": "0.0.1",
"minAppVersion": "0.15.0",
"description": "A Cursor-inspired AI assistant for Obsidian that offers smart autocomplete and interactive chat with your selected notes",
"author": "Felix.D",
"authorUrl": "https://github.com/glowingjade",
"fundingUrl": "https://buymeacoffee.com/glowingjade",
"isDesktopOnly": false
}

88
package.json Normal file
View File

@ -0,0 +1,88 @@
{
"name": "obsidian-infio-copilot",
"version": "0.0.1",
"description": "A Cursor-inspired AI assistant for Obsidian that offers smart autocomplete and interactive chat with your selected notes",
"main": "main.js",
"scripts": {
"bundle-pglite": "node scripts/bundle-pglite-resources.mjs",
"dev": "npm run bundle-pglite && node esbuild.config.mjs",
"build": "npm run bundle-pglite && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs",
"lint:check": "(prettier --check --cache --cache-strategy content --cache-location node_modules/.cache/.prettiercache .) && (eslint .)",
"lint:fix": "(prettier --write --cache --cache-strategy content --cache-location node_modules/.cache/.prettiercache .) && (eslint --fix .)",
"type:check": "tsc --noEmit",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/jest": "^29.5.13",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^16.11.6",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/semver": "^7.5.8",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"drizzle-kit": "^0.26.2",
"esbuild": "0.17.3",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-neverthrow": "^1.1.4",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^5.0.0",
"jest": "^29.7.0",
"obsidian": "latest",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"tslib": "2.4.0",
"typescript": "^5.6.2"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@electric-sql/pglite": "0.2.14",
"@google/generative-ai": "^0.21.0",
"@langchain/core": "^0.3.26",
"@lexical/clipboard": "^0.17.1",
"@lexical/react": "^0.17.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.56.2",
"clsx": "^2.1.1",
"diff": "^7.0.0",
"drizzle-orm": "^0.35.2",
"exponential-backoff": "^3.1.1",
"fuzzysort": "^3.1.0",
"groq-sdk": "^0.7.0",
"handlebars": "^4.7.7",
"js-tiktoken": "^1.0.15",
"langchain": "^0.3.2",
"lexical": "^0.17.1",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lru-cache": "^10.1.0",
"lucide-react": "^0.447.0",
"minimatch": "^10.0.1",
"neverthrow": "^6.1.0",
"openai": "^4.73.0",
"p-limit": "^6.1.0",
"parse5": "^7.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"uuid": "^10.0.0",
"zod": "^3.23.8"
}
}

View File

@ -0,0 +1,39 @@
/* eslint-disable */
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function bundlePgliteResources() {
const pgliteVersion = '0.2.14';
const pglitePath = path.resolve(__dirname, '../node_modules/@electric-sql/pglite');
// Read the files
const wasmBuffer = await fs.readFile(path.join(pglitePath, 'dist/postgres.wasm'));
const dataBuffer = await fs.readFile(path.join(pglitePath, 'dist/postgres.data'));
const vectorBuffer = await fs.readFile(path.join(pglitePath, 'dist/vector.tar.gz'));
// Convert to base64
const wasmBase64 = wasmBuffer.toString('base64');
const dataBase64 = dataBuffer.toString('base64');
const vectorBase64 = vectorBuffer.toString('base64');
// Create the output file
const output = `
// This file is auto-generated. Do not edit manually.
export const pgliteResources = {
wasmBase64: '${wasmBase64}',
dataBase64: '${dataBase64}',
vectorBase64: '${vectorBase64}',
};
`;
// Write the bundled resources
await fs.writeFile(
path.resolve(__dirname, '../src/database/pglite-resources.ts'),
output
);
}
bundlePgliteResources().catch(console.error);

53
src/ApplyView.tsx Normal file
View File

@ -0,0 +1,53 @@
import { TFile, View, WorkspaceLeaf } from 'obsidian'
import { Root, createRoot } from 'react-dom/client'
import ApplyViewRoot from './components/apply-view/ApplyViewRoot'
import { APPLY_VIEW_TYPE } from './constants'
import { AppProvider } from './contexts/AppContext'
export type ApplyViewState = {
file: TFile
originalContent: string
newContent: string
}
export class ApplyView extends View {
private root: Root | null = null
private state: ApplyViewState | null = null
constructor(leaf: WorkspaceLeaf) {
super(leaf)
}
getViewType() {
return APPLY_VIEW_TYPE
}
getDisplayText() {
return `Applying: ${this.state?.file?.name ?? ''}`
}
async setState(state: ApplyViewState) {
this.state = state
// Should render here because onOpen is called before setState
this.render()
}
async onOpen() {
this.root = createRoot(this.containerEl)
}
async onClose() {
this.root?.unmount()
}
async render() {
if (!this.root || !this.state) return
this.root.render(
<AppProvider app={this.app}>
<ApplyViewRoot state={this.state} close={() => this.leaf.detach()} />
</AppProvider>,
)
}
}

117
src/ChatView.tsx Normal file
View File

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

View File

@ -0,0 +1,142 @@
import { Change, diffLines } from 'diff'
import { CheckIcon, X } from 'lucide-react'
import { getIcon } from 'obsidian'
import { useState } from 'react'
import { ApplyViewState } from '../../ApplyView'
import { useApp } from '../../contexts/AppContext'
export default function ApplyViewRoot({
state,
close,
}: {
state: ApplyViewState
close: () => void
}) {
const acceptIcon = getIcon('check')
const rejectIcon = getIcon('x')
const excludeIcon = getIcon('x')
const app = useApp()
const [diff, setDiff] = useState<Change[]>(
diffLines(state.originalContent, state.newContent),
)
const handleAccept = async () => {
const newContent = diff
.filter((change) => !change.removed)
.map((change) => change.value)
.join('')
await app.vault.modify(state.file, newContent)
close()
}
const handleReject = async () => {
close()
}
const excludeDiffLine = (index: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff]
const change = newDiff[index]
if (change.added) {
// Remove the entry if it's an added line
return newDiff.filter((_, i) => i !== index)
} else if (change.removed) {
change.removed = false
}
return newDiff
})
}
const acceptDiffLine = (index: number) => {
setDiff((prevDiff) => {
const newDiff = [...prevDiff]
const change = newDiff[index]
if (change.added) {
change.added = false
} else if (change.removed) {
// Remove the entry if it's a removed line
return newDiff.filter((_, i) => i !== index)
}
return newDiff
})
}
return (
<div id="infio-apply-view">
<div className="view-header">
<div className="view-header-left">
<div className="view-header-nav-buttons"></div>
</div>
<div className="view-header-title-container mod-at-start">
<div className="view-header-title">
Applying: {state?.file?.name ?? ''}
</div>
<div className="view-actions">
<button
className="clickable-icon view-action infio-approve-button"
aria-label="Accept changes"
onClick={handleAccept}
>
{acceptIcon && <CheckIcon size={14} />}
Accept
</button>
<button
className="clickable-icon view-action infio-reject-button"
aria-label="Reject changes"
onClick={handleReject}
>
{rejectIcon && <X size={14} />}
Reject
</button>
</div>
</div>
</div>
<div className="view-content">
<div className="markdown-source-view cm-s-obsidian mod-cm6 node-insert-event is-readable-line-width is-live-preview is-folding show-properties">
<div className="cm-editor">
<div className="cm-scroller">
<div className="cm-sizer">
<div className="infio-inline-title">
{state?.file?.name
? state.file.name.replace(/\.[^/.]+$/, '')
: ''}
</div>
{diff.map((part, index) => (
<div
key={index}
className={`infio-diff-line ${part.added ? 'added' : part.removed ? 'removed' : ''}`}
>
<div style={{ width: '100%' }}>{part.value}</div>
{(part.added || part.removed) && (
<div className="infio-diff-line-actions">
<button
aria-label="Accept line"
onClick={() => acceptDiffLine(index)}
className="infio-accept"
>
{acceptIcon && 'Y'}
</button>
<button
aria-label="Exclude line"
onClick={() => excludeDiffLine(index)}
className="infio-exclude"
>
{excludeIcon && 'N'}
</button>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,90 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { Check, CopyIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ChatAssistantMessage } from '../../types/chat'
import { calculateLLMCost } from '../../utils/price-calculator'
import LLMResponseInfoPopover from './LLMResponseInfoPopover'
function CopyButton({ message }: { message: ChatAssistantMessage }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content)
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1500)
}
return (
<Tooltip.Provider delayDuration={0}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button>
{copied ? (
<Check
size={12}
className="infio-chat-message-actions-icon--copied"
/>
) : (
<CopyIcon onClick={handleCopy} size={12} />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="infio-tooltip-content">
Copy message
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
function LLMResponesInfoButton({ message }: { message: ChatAssistantMessage }) {
const cost = useMemo<number | null>(() => {
if (!message.metadata?.model || !message.metadata?.usage) {
return 0
}
return calculateLLMCost({
model: message.metadata.model,
usage: message.metadata.usage,
})
}, [message])
return (
<Tooltip.Provider delayDuration={0}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<LLMResponseInfoPopover
usage={message.metadata?.usage}
estimatedPrice={cost}
model={message.metadata?.model?.name}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="infio-tooltip-content">
View details
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)
}
export default function AssistantMessageActions({
message,
}: {
message: ChatAssistantMessage
}) {
return (
<div className="infio-chat-message-actions">
<LLMResponesInfoButton message={message} />
<CopyButton message={message} />
</div>
)
}

View File

@ -0,0 +1,737 @@
import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, Plus } from 'lucide-react'
import { App, Notice } from 'obsidian'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext'
import { useLLM } from '../../contexts/LLMContext'
import { useRAG } from '../../contexts/RAGContext'
import { useSettings } from '../../contexts/SettingsContext'
import {
LLMAPIKeyInvalidException,
LLMAPIKeyNotSetException,
LLMBaseUrlNotSetException,
LLMModelNotSetException,
} from '../../core/llm/exception'
import { useChatHistory } from '../../hooks/use-chat-history'
import { ChatMessage, ChatUserMessage } from '../../types/chat'
import {
MentionableBlock,
MentionableBlockData,
MentionableCurrentFile,
} from '../../types/mentionable'
import { manualApplyChangesToFile } from '../../utils/apply'
import {
getMentionableKey,
serializeMentionable,
} from '../../utils/mentionable'
import { readTFileContent } from '../../utils/obsidian'
import { openSettingsModalWithError } from '../../utils/open-settings-modal'
import { PromptGenerator } from '../../utils/prompt-generator'
import AssistantMessageActions from './AssistantMessageActions'
import ChatUserInput, { ChatUserInputRef } from './chat-input/ChatUserInput'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { ChatListDropdown } from './ChatListDropdown'
import QueryProgress, { QueryProgressState } from './QueryProgress'
import ReactMarkdown from './ReactMarkdown'
import ShortcutInfo from './ShortcutInfo'
import SimilaritySearchResults from './SimilaritySearchResults'
// Add an empty line here
const getNewInputMessage = (app: App): ChatUserMessage => {
return {
role: 'user',
content: null,
promptContent: null,
id: uuidv4(),
mentionables: [
{
type: 'current-file',
file: app.workspace.getActiveFile(),
},
],
}
}
export type ChatRef = {
openNewChat: (selectedBlock?: MentionableBlockData) => void
addSelectionToChat: (selectedBlock: MentionableBlockData) => void
focusMessage: () => void
}
export type ChatProps = {
selectedBlock?: MentionableBlockData
}
const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp()
const { settings } = useSettings()
const { getRAGEngine } = useRAG()
const {
createOrUpdateConversation,
deleteConversation,
getChatMessagesById,
updateConversationTitle,
chatList,
} = useChatHistory()
const { streamResponse, chatModel } = useLLM()
const promptGenerator = useMemo(() => {
return new PromptGenerator(getRAGEngine, app, settings)
}, [getRAGEngine, app, settings])
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
const newMessage = getNewInputMessage(app)
if (props.selectedBlock) {
newMessage.mentionables = [
...newMessage.mentionables,
{
type: 'block',
...props.selectedBlock,
},
]
}
return newMessage
})
const [addedBlockKey, setAddedBlockKey] = useState<string | null>(
props.selectedBlock
? getMentionableKey(
serializeMentionable({
type: 'block',
...props.selectedBlock,
}),
)
: null,
)
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
const [focusedMessageId, setFocusedMessageId] = useState<string | null>(null)
const [currentConversationId, setCurrentConversationId] =
useState<string>(uuidv4())
const [queryProgress, setQueryProgress] = useState<QueryProgressState>({
type: 'idle',
})
const preventAutoScrollRef = useRef(false)
const lastProgrammaticScrollRef = useRef<number>(0)
const activeStreamAbortControllersRef = useRef<AbortController[]>([])
const chatUserInputRefs = useRef<Map<string, ChatUserInputRef>>(new Map())
const chatMessagesRef = useRef<HTMLDivElement>(null)
const registerChatUserInputRef = (
id: string,
ref: ChatUserInputRef | null,
) => {
if (ref) {
chatUserInputRefs.current.set(id, ref)
} else {
chatUserInputRefs.current.delete(id)
}
}
useEffect(() => {
const scrollContainer = chatMessagesRef.current
if (!scrollContainer) return
const handleScroll = () => {
// If the scroll event happened very close to our programmatic scroll, ignore it
if (Date.now() - lastProgrammaticScrollRef.current < 50) {
return
}
preventAutoScrollRef.current =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight >
20
}
scrollContainer.addEventListener('scroll', handleScroll)
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [chatMessages])
const handleScrollToBottom = () => {
if (chatMessagesRef.current) {
const scrollContainer = chatMessagesRef.current
if (scrollContainer.scrollTop !== scrollContainer.scrollHeight) {
lastProgrammaticScrollRef.current = Date.now()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
}
const abortActiveStreams = () => {
for (const abortController of activeStreamAbortControllersRef.current) {
abortController.abort()
}
activeStreamAbortControllersRef.current = []
}
const handleLoadConversation = async (conversationId: string) => {
try {
abortActiveStreams()
const conversation = await getChatMessagesById(conversationId)
if (!conversation) {
throw new Error('Conversation not found')
}
setCurrentConversationId(conversationId)
setChatMessages(conversation)
const newInputMessage = getNewInputMessage(app)
setInputMessage(newInputMessage)
setFocusedMessageId(newInputMessage.id)
setQueryProgress({
type: 'idle',
})
} catch (error) {
new Notice('Failed to load conversation')
console.error('Failed to load conversation', error)
}
}
const handleNewChat = (selectedBlock?: MentionableBlockData) => {
setCurrentConversationId(uuidv4())
setChatMessages([])
const newInputMessage = getNewInputMessage(app)
if (selectedBlock) {
const mentionableBlock: MentionableBlock = {
type: 'block',
...selectedBlock,
}
newInputMessage.mentionables = [
...newInputMessage.mentionables,
mentionableBlock,
]
setAddedBlockKey(
getMentionableKey(serializeMentionable(mentionableBlock)),
)
}
setInputMessage(newInputMessage)
setFocusedMessageId(newInputMessage.id)
setQueryProgress({
type: 'idle',
})
abortActiveStreams()
}
const submitMutation = useMutation({
mutationFn: async ({
newChatHistory,
useVaultSearch,
}: {
newChatHistory: ChatMessage[]
useVaultSearch?: boolean
}) => {
abortActiveStreams()
setQueryProgress({
type: 'idle',
})
const responseMessageId = uuidv4()
setChatMessages([
...newChatHistory,
{
role: 'assistant',
content: '',
id: responseMessageId,
metadata: {
usage: undefined,
model: undefined,
},
},
])
try {
const abortController = new AbortController()
activeStreamAbortControllersRef.current.push(abortController)
const { requestMessages, compiledMessages } =
await promptGenerator.generateRequestMessages({
messages: newChatHistory,
useVaultSearch,
onQueryProgressChange: setQueryProgress,
})
setQueryProgress({
type: 'idle',
})
setChatMessages([
...compiledMessages,
{
role: 'assistant',
content: '',
id: responseMessageId,
metadata: {
usage: undefined,
model: undefined,
},
},
])
const stream = await streamResponse(
chatModel,
{
model: chatModel.name,
messages: requestMessages,
stream: true,
},
{
signal: abortController.signal,
},
)
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content ?? ''
setChatMessages((prevChatHistory) =>
prevChatHistory.map((message) =>
message.role === 'assistant' && message.id === responseMessageId
? {
...message,
content: message.content + content,
metadata: {
...message.metadata,
usage: chunk.usage ?? message.metadata?.usage, // Keep existing usage if chunk has no usage data
model: chatModel,
},
}
: message,
),
)
if (!preventAutoScrollRef.current) {
handleScrollToBottom()
}
}
// for debugging
setChatMessages((prevChatHistory) => {
const lastMessage = prevChatHistory[prevChatHistory.length - 1];
console.log("Last complete message:", lastMessage?.content);
return prevChatHistory;
});
} catch (error) {
if (error.name === 'AbortError') {
return
} else {
throw error
}
}
},
onError: (error) => {
setQueryProgress({
type: 'idle',
})
if (
error instanceof LLMAPIKeyNotSetException ||
error instanceof LLMAPIKeyInvalidException ||
error instanceof LLMBaseUrlNotSetException ||
error instanceof LLMModelNotSetException
) {
openSettingsModalWithError(app, error.message)
} else {
new Notice(error.message)
console.error('Failed to generate response', error)
}
},
})
const handleSubmit = (
newChatHistory: ChatMessage[],
useVaultSearch?: boolean,
) => {
submitMutation.mutate({ newChatHistory, useVaultSearch })
}
const applyMutation = useMutation({
mutationFn: async ({
blockInfo,
}: {
blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}
}) => {
const activeFile = app.workspace.getActiveFile()
if (!activeFile) {
throw new Error(
'No file is currently open to apply changes. Please open a file and try again.',
)
}
const activeFileContent = await readTFileContent(activeFile, app.vault)
const updatedFileContent = await manualApplyChangesToFile(
blockInfo.content,
activeFile,
activeFileContent,
blockInfo.startLine,
blockInfo.endLine
)
if (!updatedFileContent) {
throw new Error('Failed to apply changes')
}
await app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: activeFileContent,
newContent: updatedFileContent,
} satisfies ApplyViewState,
})
},
onError: (error) => {
if (
error instanceof LLMAPIKeyNotSetException ||
error instanceof LLMAPIKeyInvalidException ||
error instanceof LLMBaseUrlNotSetException ||
error instanceof LLMModelNotSetException
) {
openSettingsModalWithError(app, error.message)
} else {
new Notice(error.message)
console.error('Failed to apply changes', error)
}
},
})
const handleApply = useCallback(
(blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => {
applyMutation.mutate({ blockInfo })
},
[applyMutation],
)
useEffect(() => {
setFocusedMessageId(inputMessage.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
//
useEffect(() => {
const updateConversationAsync = async () => {
try {
if (chatMessages.length > 0) {
createOrUpdateConversation(currentConversationId, chatMessages)
}
} catch (error) {
new Notice('Failed to save chat history')
console.error('Failed to save chat history', error)
}
}
updateConversationAsync()
}, [currentConversationId, chatMessages, createOrUpdateConversation])
// Updates the currentFile of the focused message (input or chat history)
// This happens when active file changes or focused message changes
const handleActiveLeafChange = useCallback(() => {
const activeFile = app.workspace.getActiveFile()
if (!activeFile) return
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
type: 'current-file',
file: activeFile,
}
if (!focusedMessageId) return
if (inputMessage.id === focusedMessageId) {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables: [
mentionable,
...prevInputMessage.mentionables.filter(
(mentionable) => mentionable.type !== 'current-file',
),
],
}))
} else {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((message) =>
message.id === focusedMessageId && message.role === 'user'
? {
...message,
mentionables: [
mentionable,
...message.mentionables.filter(
(mentionable) => mentionable.type !== 'current-file',
),
],
}
: message,
),
)
}
}, [app.workspace, focusedMessageId, inputMessage.id])
useEffect(() => {
app.workspace.on('active-leaf-change', handleActiveLeafChange)
return () => {
app.workspace.off('active-leaf-change', handleActiveLeafChange)
}
}, [app.workspace, handleActiveLeafChange])
useImperativeHandle(ref, () => ({
openNewChat: (selectedBlock?: MentionableBlockData) =>
handleNewChat(selectedBlock),
addSelectionToChat: (selectedBlock: MentionableBlockData) => {
const mentionable: Omit<MentionableBlock, 'id'> = {
type: 'block',
...selectedBlock,
}
setAddedBlockKey(getMentionableKey(serializeMentionable(mentionable)))
if (focusedMessageId === inputMessage.id) {
setInputMessage((prevInputMessage) => {
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
// Check if mentionable already exists
if (
prevInputMessage.mentionables.some(
(m) =>
getMentionableKey(serializeMentionable(m)) === mentionableKey,
)
) {
return prevInputMessage
}
return {
...prevInputMessage,
mentionables: [...prevInputMessage.mentionables, mentionable],
}
})
} else {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((message) => {
if (message.id === focusedMessageId && message.role === 'user') {
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
// Check if mentionable already exists
if (
message.mentionables.some(
(m) =>
getMentionableKey(serializeMentionable(m)) ===
mentionableKey,
)
) {
return message
}
return {
...message,
mentionables: [...message.mentionables, mentionable],
}
}
return message
}),
)
}
},
focusMessage: () => {
if (!focusedMessageId) return
chatUserInputRefs.current.get(focusedMessageId)?.focus()
},
}))
return (
<div className="infio-chat-container">
<div className="infio-chat-header">
<h1 className="infio-chat-header-title"> CHAT </h1>
<div className="infio-chat-header-buttons">
<button
onClick={() => handleNewChat()}
className="infio-chat-list-dropdown"
>
<Plus size={18} />
</button>
<ChatListDropdown
chatList={chatList}
currentConversationId={currentConversationId}
onSelect={async (conversationId) => {
if (conversationId === currentConversationId) return
await handleLoadConversation(conversationId)
}}
onDelete={async (conversationId) => {
await deleteConversation(conversationId)
if (conversationId === currentConversationId) {
const nextConversation = chatList.find(
(chat) => chat.id !== conversationId,
)
if (nextConversation) {
void handleLoadConversation(nextConversation.id)
} else {
handleNewChat()
}
}
}}
onUpdateTitle={async (conversationId, newTitle) => {
await updateConversationTitle(conversationId, newTitle)
}}
className="infio-chat-list-dropdown"
>
<History size={18} />
</ChatListDropdown>
</div>
</div>
<div className="infio-chat-messages" ref={chatMessagesRef}>
{
// If the chat is empty, show a message to start a new chat
chatMessages.length === 0 && (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%'
}}>
<ShortcutInfo />
</div>
)
}
{chatMessages.map((message, index) =>
message.role === 'user' ? (
<div key={message.id} className="infio-chat-messages-user">
<ChatUserInput
ref={(ref) => registerChatUserInputRef(message.id, ref)}
initialSerializedEditorState={message.content}
onChange={(content) => {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((msg) =>
msg.role === 'user' && msg.id === message.id
? {
...msg,
content,
}
: msg,
),
)
}}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[
...chatMessages.slice(0, index),
{
role: 'user',
content: content,
promptContent: null,
id: message.id,
mentionables: message.mentionables,
},
],
useVaultSearch,
)
chatUserInputRefs.current.get(inputMessage.id)?.focus()
}}
onFocus={() => {
setFocusedMessageId(message.id)
}}
mentionables={message.mentionables}
setMentionables={(mentionables) => {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((msg) =>
msg.id === message.id ? { ...msg, mentionables } : msg,
),
)
}}
/>
{message.similaritySearchResults && (
<SimilaritySearchResults
similaritySearchResults={message.similaritySearchResults}
/>
)}
</div>
) : (
<div key={message.id} className="infio-chat-messages-assistant">
<ReactMarkdownItem
handleApply={handleApply}
isApplying={applyMutation.isPending}
>
{message.content}
</ReactMarkdownItem>
{message.content && <AssistantMessageActions message={message} />}
</div>
),
)}
<QueryProgress state={queryProgress} />
{submitMutation.isPending && (
<button onClick={abortActiveStreams} className="infio-stop-gen-btn">
<CircleStop size={16} />
<div>Stop Generation</div>
</button>
)}
</div>
<ChatUserInput
key={inputMessage.id} // this is needed to clear the editor when the user submits a new message
ref={(ref) => registerChatUserInputRef(inputMessage.id, ref)}
initialSerializedEditorState={inputMessage.content}
onChange={(content) => {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
content,
}))
}}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[...chatMessages, { ...inputMessage, content }],
useVaultSearch,
)
setInputMessage(getNewInputMessage(app))
preventAutoScrollRef.current = false
handleScrollToBottom()
}}
onFocus={() => {
setFocusedMessageId(inputMessage.id)
}}
mentionables={inputMessage.mentionables}
setMentionables={(mentionables) => {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables,
}))
}}
autoFocus
addedBlockKey={addedBlockKey}
/>
</div>
)
})
function ReactMarkdownItem({
handleApply,
isApplying,
children,
}: {
handleApply: (blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => void
isApplying: boolean
children: string
}) {
return (
<ReactMarkdown onApply={handleApply} isApplying={isApplying}>
{children}
</ReactMarkdown>
)
}
Chat.displayName = 'Chat'
export default Chat

View File

@ -0,0 +1,202 @@
import * as Popover from '@radix-ui/react-popover'
import { Pencil, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChatConversationMeta } from '../../types/chat'
function TitleInput({
title,
onSubmit,
}: {
title: string
onSubmit: (title: string) => Promise<void>
}) {
const [value, setValue] = useState(title)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.select()
inputRef.current.scrollLeft = 0
}
}, [])
return (
<input
ref={inputRef}
type="text"
value={value}
className="infio-chat-list-dropdown-item-title-input"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
onSubmit(value)
}
}}
autoFocus
maxLength={100}
/>
)
}
function ChatListItem({
title,
isFocused,
isEditing,
onMouseEnter,
onSelect,
onDelete,
onStartEdit,
onFinishEdit,
}: {
title: string
isFocused: boolean
isEditing: boolean
onMouseEnter: () => void
onSelect: () => Promise<void>
onDelete: () => Promise<void>
onStartEdit: () => void
onFinishEdit: (title: string) => Promise<void>
}) {
const itemRef = useRef<HTMLLIElement>(null)
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.scrollIntoView({
block: 'nearest',
})
}
}, [isFocused])
return (
<li
ref={itemRef}
onClick={onSelect}
onMouseEnter={onMouseEnter}
className={isFocused ? 'selected' : ''}
>
{isEditing ? (
<TitleInput title={title} onSubmit={onFinishEdit} />
) : (
<div className="infio-chat-list-dropdown-item-title">{title}</div>
)}
<div className="infio-chat-list-dropdown-item-actions">
<div
onClick={(e) => {
e.stopPropagation()
onStartEdit()
}}
className="infio-chat-list-dropdown-item-icon"
>
<Pencil size={14} />
</div>
<div
onClick={async (e) => {
e.stopPropagation()
await onDelete()
}}
className="infio-chat-list-dropdown-item-icon"
>
<Trash2 size={14} />
</div>
</div>
</li>
)
}
export function ChatListDropdown({
chatList,
currentConversationId,
onSelect,
onDelete,
onUpdateTitle,
className,
children,
}: {
chatList: ChatConversationMeta[]
currentConversationId: string
onSelect: (conversationId: string) => Promise<void>
onDelete: (conversationId: string) => Promise<void>
onUpdateTitle: (conversationId: string, newTitle: string) => Promise<void>
className?: string
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
const [focusedIndex, setFocusedIndex] = useState<number>(0)
const [editingId, setEditingId] = useState<string | null>(null)
useEffect(() => {
if (open) {
const currentIndex = chatList.findIndex(
(chat) => chat.id === currentConversationId,
)
setFocusedIndex(currentIndex === -1 ? 0 : currentIndex)
setEditingId(null)
}
}, [open])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp') {
setFocusedIndex(Math.max(0, focusedIndex - 1))
} else if (e.key === 'ArrowDown') {
setFocusedIndex(Math.min(chatList.length - 1, focusedIndex + 1))
} else if (e.key === 'Enter') {
onSelect(chatList[focusedIndex].id)
setOpen(false)
}
},
[chatList, focusedIndex, setFocusedIndex, onSelect],
)
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button className={className}>{children}</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="infio-popover infio-chat-list-dropdown-content"
onKeyDown={handleKeyDown}
>
<ul>
{chatList.length === 0 ? (
<li className="infio-chat-list-dropdown-empty">
No conversations
</li>
) : (
chatList.map((chat, index) => (
<ChatListItem
key={chat.id}
title={chat.title}
isFocused={focusedIndex === index}
isEditing={editingId === chat.id}
onMouseEnter={() => {
setFocusedIndex(index)
}}
onSelect={async () => {
await onSelect(chat.id)
setOpen(false)
}}
onDelete={async () => {
await onDelete(chat.id)
}}
onStartEdit={() => {
setEditingId(chat.id)
}}
onFinishEdit={async (title) => {
await onUpdateTitle(chat.id, title)
setEditingId(null)
}}
/>
))
)}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)
}

View File

@ -0,0 +1,127 @@
import { $generateNodesFromSerializedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { InitialEditorStateType } from '@lexical/react/LexicalComposer'
import * as Dialog from '@radix-ui/react-dialog'
import { $insertNodes, LexicalEditor } from 'lexical'
import { X } from 'lucide-react'
import { Notice } from 'obsidian'
import { useRef, useState } from 'react'
import { useDatabase } from '../../contexts/DatabaseContext'
import { useDialogContainer } from '../../contexts/DialogContext'
import { DuplicateTemplateException } from '../../database/exception'
import LexicalContentEditable from './chat-input/LexicalContentEditable'
/*
* This component must be used inside <Dialog.Root modal={false}>
* The modal={false} prop is required because modal mode blocks pointer events across the entire page,
* which would conflict with lexical editor popovers
*/
export default function CreateTemplateDialogContent({
selectedSerializedNodes,
onClose,
}: {
selectedSerializedNodes?: BaseSerializedNode[] | null
onClose: () => void
}) {
const container = useDialogContainer()
const { getTemplateManager } = useDatabase()
const [templateName, setTemplateName] = useState('')
const editorRef = useRef<LexicalEditor | null>(null)
const contentEditableRef = useRef<HTMLDivElement>(null)
const initialEditorState: InitialEditorStateType = (
editor: LexicalEditor,
) => {
if (!selectedSerializedNodes) return
editor.update(() => {
const parsedNodes = $generateNodesFromSerializedNodes(
selectedSerializedNodes,
)
$insertNodes(parsedNodes)
})
}
const onSubmit = async () => {
try {
if (!editorRef.current) return
const serializedEditorState = editorRef.current.toJSON()
const nodes = serializedEditorState.editorState.root.children
if (nodes.length === 0) {
new Notice('Please enter a content for your template')
return
}
if (templateName.trim().length === 0) {
new Notice('Please enter a name for your template')
return
}
await (
await getTemplateManager()
).createTemplate({
name: templateName,
content: { nodes },
})
new Notice(`Template created: ${templateName}`)
setTemplateName('')
onClose()
} catch (error) {
if (error instanceof DuplicateTemplateException) {
new Notice('A template with this name already exists')
} else {
console.error(error)
new Notice('Failed to create template')
}
}
}
return (
<Dialog.Portal container={container}>
<Dialog.Content className="infio-chat-dialog-content">
<div className="infio-dialog-header">
<Dialog.Title className="infio-dialog-title">
Create template
</Dialog.Title>
<Dialog.Description className="infio-dialog-description">
Create template from selected content
</Dialog.Description>
</div>
<div className="infio-dialog-input">
<label>Name</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
e.preventDefault()
onSubmit()
}
}}
/>
</div>
<div className="infio-chat-user-input-container">
<LexicalContentEditable
initialEditorState={initialEditorState}
editorRef={editorRef}
contentEditableRef={contentEditableRef}
onEnter={onSubmit}
/>
</div>
<div className="infio-dialog-footer">
<button onClick={onSubmit}>Create template</button>
</div>
<Dialog.Close className="infio-dialog-close" asChild>
<X size={16} />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
)
}

View File

@ -0,0 +1,84 @@
import * as Popover from '@radix-ui/react-popover'
import {
ArrowDown,
ArrowRightLeft,
ArrowUp,
Coins,
Cpu,
Info,
} from 'lucide-react'
import { ResponseUsage } from '../../types/llm/response'
type LLMResponseInfoProps = {
usage?: ResponseUsage
estimatedPrice: number | null
model?: string
}
export default function LLMResponseInfoPopover({
usage,
estimatedPrice,
model,
}: LLMResponseInfoProps) {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button>
<Info className="infio-llm-info-icon--trigger" size={12} />
</button>
</Popover.Trigger>
{usage ? (
<Popover.Content className="infio-chat-popover-content infio-llm-info-content">
<div className="infio-llm-info-header">LLM Response Information</div>
<div className="infio-llm-info-tokens">
<div className="infio-llm-info-tokens-header">Token Count</div>
<div className="infio-llm-info-tokens-grid">
<div className="infio-llm-info-token-row">
<ArrowUp className="infio-llm-info-icon--input" />
<span>Input:</span>
<span className="infio-llm-info-token-value">
{usage.prompt_tokens}
</span>
</div>
<div className="infio-llm-info-token-row">
<ArrowDown className="infio-llm-info-icon--output" />
<span>Output:</span>
<span className="infio-llm-info-token-value">
{usage.completion_tokens}
</span>
</div>
<div className="infio-llm-info-token-row infio-llm-info-token-total">
<ArrowRightLeft className="infio-llm-info-icon--total" />
<span>Total:</span>
<span className="infio-llm-info-token-value">
{usage.total_tokens}
</span>
</div>
</div>
</div>
<div className="infio-llm-info-footer-row">
<Coins className="infio-llm-info-icon--footer" />
<span>Estimated Price:</span>
<span className="infio-llm-info-footer-value">
{estimatedPrice === null
? 'Not available'
: `$${estimatedPrice.toFixed(4)}`}
</span>
</div>
<div className="infio-llm-info-footer-row">
<Cpu className="infio-llm-info-icon--footer" />
<span>Model:</span>
<span className="infio-llm-info-footer-value infio-llm-info-model">
{model ?? 'Not available'}
</span>
</div>
</Popover.Content>
) : (
<Popover.Content className="infio-chat-popover-content">
<div>Usage statistics are not available for this model</div>
</Popover.Content>
)}
</Popover.Root>
)
}

View File

@ -0,0 +1,122 @@
import { Check, CopyIcon, Loader2 } from 'lucide-react'
import { PropsWithChildren, useMemo, useState } from 'react'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
export default function MarkdownCodeComponent({
onApply,
isApplying,
language,
filename,
startLine,
endLine,
action,
children,
}: PropsWithChildren<{
onApply: (blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => void
isApplying: boolean
language?: string
filename?: string
startLine?: number
endLine?: number
action?: 'edit' | 'new' | 'reference'
}>) {
const [copied, setCopied] = useState(false)
const { isDarkMode } = useDarkModeContext()
const wrapLines = useMemo(() => {
return !language || ['markdown'].includes(language)
}, [language])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(String(children))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy text: ', err)
}
}
return (
<div className={`infio-chat-code-block ${filename ? 'has-filename' : ''} ${action ? `type-${action}` : ''}`}>
<div className={'infio-chat-code-block-header'}>
{filename && (
<div className={'infio-chat-code-block-header-filename'}>{filename}</div>
)}
<div className={'infio-chat-code-block-header-button'}>
<button
onClick={() => {
handleCopy()
}}
>
{copied ? (
<>
<Check size={10} /> Copied
</>
) : (
<>
<CopyIcon size={10} /> Copy
</>
)}
</button>
{action === 'edit' && (
<button
onClick={() => {
onApply({
content: String(children),
filename,
startLine,
endLine
})
}}
disabled={isApplying}
>
{isApplying ? (
<>
<Loader2 className="spinner" size={14} /> Applying...
</>
) : (
'Apply'
)}
</button>
)}
{action === 'new' && (
<button
onClick={() => {
onApply({
content: String(children),
filename
})
}}
disabled={isApplying}
>
{isApplying ? (
<>
<Loader2 className="spinner" size={14} /> Inserting...
</>
) : (
'Insert'
)}
</button>
)}
</div>
</div>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language={language}
hasFilename={!!filename}
wrapLines={wrapLines}
>
{String(children)}
</MemoizedSyntaxHighlighterWrapper>
</div>
)
}

View File

@ -0,0 +1,75 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useApp } from '../../contexts/AppContext'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
import { openMarkdownFile, readTFileContent } from '../../utils/obsidian'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
export default function MarkdownReferenceBlock({
filename,
startLine,
endLine,
language,
}: PropsWithChildren<{
filename: string
startLine: number
endLine: number
language?: string
}>) {
const app = useApp()
const { isDarkMode } = useDarkModeContext()
const [blockContent, setBlockContent] = useState<string | null>(null)
const wrapLines = useMemo(() => {
return !language || ['markdown'].includes(language)
}, [language])
useEffect(() => {
async function fetchBlockContent() {
const file = app.vault.getFileByPath(filename)
if (!file) {
setBlockContent(null)
return
}
const fileContent = await readTFileContent(file, app.vault)
const content = fileContent
.split('\n')
.slice(startLine - 1, endLine)
.join('\n')
setBlockContent(content)
}
fetchBlockContent()
}, [filename, startLine, endLine, app.vault])
const handleClick = () => {
openMarkdownFile(app, filename, startLine)
}
// TODO: Update styles
return (
blockContent && (
<div
className={`infio-chat-code-block ${filename ? 'has-filename' : ''}`}
onClick={handleClick}
>
<div className={'infio-chat-code-block-header'}>
{filename && (
<div className={'infio-chat-code-block-header-filename'}>
{filename}
</div>
)}
</div>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language={language}
hasFilename={!!filename}
wrapLines={wrapLines}
>
{blockContent}
</MemoizedSyntaxHighlighterWrapper>
</div>
)
)
}

View File

@ -0,0 +1,85 @@
import { SelectVector } from '../../database/schema'
export type QueryProgressState =
| {
type: 'reading-mentionables'
}
| {
type: 'indexing'
indexProgress: IndexProgress
}
| {
type: 'querying'
}
| {
type: 'querying-done'
queryResult: (Omit<SelectVector, 'embedding'> & { similarity: number })[]
}
| {
type: 'idle'
}
export type IndexProgress = {
completedChunks: number
totalChunks: number
totalFiles: number
}
// TODO: Update style
export default function QueryProgress({
state,
}: {
state: QueryProgressState
}) {
switch (state.type) {
case 'idle':
return null
case 'reading-mentionables':
return (
<div className="infio-query-progress">
<p>
Reading mentioned files
<DotLoader />
</p>
</div>
)
case 'indexing':
return (
<div className="infio-query-progress">
<p>
{`Indexing ${state.indexProgress.totalFiles} file`}
<DotLoader />
</p>
<p className="infio-query-progress-detail">{`${state.indexProgress.completedChunks}/${state.indexProgress.totalChunks} chunks indexed`}</p>
</div>
)
case 'querying':
return (
<div className="infio-query-progress">
<p>
Querying the vault
<DotLoader />
</p>
</div>
)
case 'querying-done':
return (
<div className="infio-query-progress">
<p>
Reading related files
<DotLoader />
</p>
{state.queryResult.map((result) => (
<div key={result.path}>
<p>{result.path}</p>
<p>{result.similarity}</p>
</div>
))}
</div>
)
}
}
function DotLoader() {
return <span className="infio-dot-loader" aria-label="Loading"></span>
}

View File

@ -0,0 +1,64 @@
import React, { useMemo } from 'react'
import Markdown from 'react-markdown'
import {
ParsedinfioBlock,
parseinfioBlocks,
} from '../../utils/parse-infio-block'
import MarkdownCodeComponent from './MarkdownCodeComponent'
import MarkdownReferenceBlock from './MarkdownReferenceBlock'
function ReactMarkdown({
onApply,
isApplying,
children,
}: {
onApply: (blockInfo: {
content: string
filename?: string
startLine?: number
endLine?: number
}) => void
children: string
isApplying: boolean
}) {
const blocks: ParsedinfioBlock[] = useMemo(
() => parseinfioBlocks(children),
[children],
)
return (
<>
{blocks.map((block, index) =>
block.type === 'string' ? (
<Markdown key={index} className="infio-markdown">
{block.content}
</Markdown>
) : block.startLine && block.endLine && block.filename && block.action === 'reference' ? (
<MarkdownReferenceBlock
key={index}
filename={block.filename}
startLine={block.startLine}
endLine={block.endLine}
/>
) : (
<MarkdownCodeComponent
key={index}
onApply={onApply}
isApplying={isApplying}
language={block.language}
filename={block.filename}
startLine={block.startLine}
endLine={block.endLine}
action={block.action}
>
{block.content}
</MarkdownCodeComponent>
),
)}
</>
)
}
export default React.memo(ReactMarkdown)

View File

@ -0,0 +1,38 @@
import { Platform } from 'obsidian';
import React from 'react';
const ShortcutInfo: React.FC = () => {
const modKey = Platform.isMacOS ? 'Cmd' : 'Ctrl';
const shortcuts = [
{
label: 'Edit inline',
shortcut: `${modKey}+Shift+K`,
},
{
label: 'Chat with select',
shortcut: `${modKey}+Shift+L`,
},
{
label: 'Submit with vault',
shortcut: `${modKey}+Shift+Enter`,
}
];
return (
<div className="infio-shortcut-info">
<table className="infio-shortcut-table">
<tbody>
{shortcuts.map((item, index) => (
<tr key={index} className="infio-shortcut-item">
<td className="infio-shortcut-label">{item.label}</td>
<td className="infio-shortcut-key"><kbd>{item.shortcut}</kbd></td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ShortcutInfo;

View File

@ -0,0 +1,71 @@
import path from 'path'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useState } from 'react'
import { useApp } from '../../contexts/AppContext'
import { SelectVector } from '../../database/schema'
import { openMarkdownFile } from '../../utils/obsidian'
function SimiliartySearchItem({
chunk,
}: {
chunk: Omit<SelectVector, 'embedding'> & {
similarity: number
}
}) {
const app = useApp()
const handleClick = () => {
openMarkdownFile(app, chunk.path, chunk.metadata.startLine)
}
return (
<div onClick={handleClick} className="infio-similarity-search-item">
<div className="infio-similarity-search-item__similarity">
{chunk.similarity.toFixed(3)}
</div>
<div className="infio-similarity-search-item__path">
{path.basename(chunk.path)}
</div>
<div className="infio-similarity-search-item__line-numbers">
{`${chunk.metadata.startLine} - ${chunk.metadata.endLine}`}
</div>
</div>
)
}
export default function SimilaritySearchResults({
similaritySearchResults,
}: {
similaritySearchResults: (Omit<SelectVector, 'embedding'> & {
similarity: number
})[]
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="infio-similarity-search-results">
<div
onClick={() => {
setIsOpen(!isOpen)
}}
className="infio-similarity-search-results__trigger"
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div>Show Referenced Documents ({similaritySearchResults.length})</div>
</div>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
{similaritySearchResults.map((chunk) => (
<SimiliartySearchItem key={chunk.id} chunk={chunk} />
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,51 @@
import { memo } from 'react'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism'
function SyntaxHighlighterWrapper({
isDarkMode,
language,
hasFilename,
wrapLines,
children,
}: {
isDarkMode: boolean
language: string | undefined
hasFilename: boolean
wrapLines: boolean
children: string
}) {
return (
<SyntaxHighlighter
language={language}
style={isDarkMode ? oneDark : oneLight}
customStyle={{
borderRadius: hasFilename
? '0 0 var(--radius-s) var(--radius-s)'
: 'var(--radius-s)',
margin: 0,
padding: 'var(--size-4-2)',
fontSize: 'var(--font-ui-small)',
fontFamily:
language === 'markdown' ? 'var(--font-interface)' : 'inherit',
}}
wrapLines={wrapLines}
lineProps={
// Wrapping should work without lineProps, but Obsidian's default CSS seems to override SyntaxHighlighter's styles.
// We manually override the white-space property to ensure proper wrapping.
wrapLines
? {
style: { whiteSpace: 'pre-wrap' },
}
: undefined
}
>
{children}
</SyntaxHighlighter>
)
}
export const MemoizedSyntaxHighlighterWrapper = memo(SyntaxHighlighterWrapper)

View File

@ -0,0 +1,374 @@
import { useQuery } from '@tanstack/react-query'
import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useApp } from '../../../contexts/AppContext'
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
import {
Mentionable,
MentionableImage,
SerializedMentionable,
} from '../../../types/mentionable'
import { fileToMentionableImage } from '../../../utils/image'
import {
deserializeMentionable,
getMentionableKey,
serializeMentionable,
} from '../../../utils/mentionable'
import { openMarkdownFile, readTFileContent } from '../../../utils/obsidian'
import { MemoizedSyntaxHighlighterWrapper } from '../SyntaxHighlighterWrapper'
import { ImageUploadButton } from './ImageUploadButton'
import LexicalContentEditable from './LexicalContentEditable'
import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
import { MentionNode } from './plugins/mention/MentionNode'
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
import { SubmitButton } from './SubmitButton'
import { VaultChatButton } from './VaultChatButton'
export type ChatUserInputRef = {
focus: () => void
}
export type ChatUserInputProps = {
initialSerializedEditorState: SerializedEditorState | null
onChange: (content: SerializedEditorState) => void
onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void
onFocus: () => void
mentionables: Mentionable[]
setMentionables: (mentionables: Mentionable[]) => void
autoFocus?: boolean
addedBlockKey?: string | null
}
const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
(
{
initialSerializedEditorState,
onChange,
onSubmit,
onFocus,
mentionables,
setMentionables,
autoFocus = false,
addedBlockKey,
},
ref,
) => {
const app = useApp()
const editorRef = useRef<LexicalEditor | null>(null)
const contentEditableRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [displayedMentionableKey, setDisplayedMentionableKey] = useState<
string | null
>(addedBlockKey ?? null)
useEffect(() => {
if (addedBlockKey) {
setDisplayedMentionableKey(addedBlockKey)
}
}, [addedBlockKey])
useImperativeHandle(ref, () => ({
focus: () => {
contentEditableRef.current?.focus()
},
}))
const handleMentionNodeMutation = (
mutations: NodeMutations<MentionNode>,
) => {
const destroyedMentionableKeys: string[] = []
const addedMentionables: SerializedMentionable[] = []
mutations.forEach((mutation) => {
const mentionable = mutation.node.getMentionable()
const mentionableKey = getMentionableKey(mentionable)
if (mutation.mutation === 'destroyed') {
const nodeWithSameMentionable = editorRef.current?.read(() =>
$nodesOfType(MentionNode).find(
(node) =>
getMentionableKey(node.getMentionable()) === mentionableKey,
),
)
if (!nodeWithSameMentionable) {
// remove mentionable only if it's not present in the editor state
destroyedMentionableKeys.push(mentionableKey)
}
} else if (mutation.mutation === 'created') {
if (
mentionables.some(
(m) =>
getMentionableKey(serializeMentionable(m)) === mentionableKey,
) ||
addedMentionables.some(
(m) => getMentionableKey(m) === mentionableKey,
)
) {
// do nothing if mentionable is already added
return
}
addedMentionables.push(mentionable)
}
})
setMentionables(
mentionables
.filter(
(m) =>
!destroyedMentionableKeys.includes(
getMentionableKey(serializeMentionable(m)),
),
)
.concat(
addedMentionables
.map((m) => deserializeMentionable(m, app))
.filter((v) => !!v),
),
)
if (addedMentionables.length > 0) {
setDisplayedMentionableKey(
getMentionableKey(addedMentionables[addedMentionables.length - 1]),
)
}
}
const handleCreateImageMentionables = useCallback(
(mentionableImages: MentionableImage[]) => {
const newMentionableImages = mentionableImages.filter(
(m) =>
!mentionables.some(
(mentionable) =>
getMentionableKey(serializeMentionable(mentionable)) ===
getMentionableKey(serializeMentionable(m)),
),
)
if (newMentionableImages.length === 0) return
setMentionables([...mentionables, ...newMentionableImages])
setDisplayedMentionableKey(
getMentionableKey(
serializeMentionable(
newMentionableImages[newMentionableImages.length - 1],
),
),
)
},
[mentionables, setMentionables],
)
const handleMentionableDelete = (mentionable: Mentionable) => {
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
setMentionables(
mentionables.filter(
(m) => getMentionableKey(serializeMentionable(m)) !== mentionableKey,
),
)
editorRef.current?.update(() => {
$nodesOfType(MentionNode).forEach((node) => {
if (getMentionableKey(node.getMentionable()) === mentionableKey) {
node.remove()
}
})
})
}
const handleUploadImages = async (images: File[]) => {
const mentionableImages = await Promise.all(
images.map((image) => fileToMentionableImage(image)),
)
handleCreateImageMentionables(mentionableImages)
}
const handleSubmit = (options: { useVaultSearch?: boolean } = {}) => {
const content = editorRef.current?.getEditorState()?.toJSON()
content && onSubmit(content, options.useVaultSearch)
}
return (
<div className="infio-chat-user-input-container" ref={containerRef}>
{mentionables.length > 0 && (
<div className="infio-chat-user-input-files">
{mentionables.map((m) => (
<MentionableBadge
key={getMentionableKey(serializeMentionable(m))}
mentionable={m}
onDelete={() => handleMentionableDelete(m)}
onClick={() => {
const mentionableKey = getMentionableKey(
serializeMentionable(m),
)
if (
(m.type === 'current-file' ||
m.type === 'file' ||
m.type === 'block') &&
m.file &&
mentionableKey === displayedMentionableKey
) {
// open file on click again
openMarkdownFile(
app,
m.file.path,
m.type === 'block' ? m.startLine : undefined,
)
} else {
setDisplayedMentionableKey(mentionableKey)
}
}}
isFocused={
getMentionableKey(serializeMentionable(m)) ===
displayedMentionableKey
}
/>
))}
</div>
)}
<MentionableContentPreview
displayedMentionableKey={displayedMentionableKey}
mentionables={mentionables}
/>
<LexicalContentEditable
initialEditorState={(editor) => {
if (initialSerializedEditorState) {
editor.setEditorState(
editor.parseEditorState(initialSerializedEditorState),
)
}
}}
editorRef={editorRef}
contentEditableRef={contentEditableRef}
onChange={onChange}
onEnter={() => handleSubmit({ useVaultSearch: false })}
onFocus={onFocus}
onMentionNodeMutation={handleMentionNodeMutation}
onCreateImageMentionables={handleCreateImageMentionables}
autoFocus={autoFocus}
plugins={{
onEnter: {
onVaultChat: () => {
handleSubmit({ useVaultSearch: true })
},
},
templatePopover: {
anchorElement: containerRef.current,
},
}}
/>
<div className="infio-chat-user-input-controls">
<div className="infio-chat-user-input-controls__model-select-container">
<ModelSelect />
<ImageUploadButton onUpload={handleUploadImages} />
</div>
<div className="infio-chat-user-input-controls__buttons">
<SubmitButton onClick={() => handleSubmit()} />
{/* <VaultChatButton
onClick={() => {
handleSubmit({ useVaultSearch: true })
}}
/> */}
</div>
</div>
</div>
)
},
)
function MentionableContentPreview({
displayedMentionableKey,
mentionables,
}: {
displayedMentionableKey: string | null
mentionables: Mentionable[]
}) {
const app = useApp()
const { isDarkMode } = useDarkModeContext()
const displayedMentionable: Mentionable | null = useMemo(() => {
return (
mentionables.find(
(m) =>
getMentionableKey(serializeMentionable(m)) ===
displayedMentionableKey,
) ?? null
)
}, [displayedMentionableKey, mentionables])
const { data: displayFileContent } = useQuery({
enabled:
!!displayedMentionable &&
['file', 'current-file', 'block'].includes(displayedMentionable.type),
queryKey: [
'file',
displayedMentionableKey,
mentionables.map((m) => getMentionableKey(serializeMentionable(m))), // should be updated when mentionables change (especially on delete)
],
queryFn: async () => {
if (!displayedMentionable) return null
if (
displayedMentionable.type === 'file' ||
displayedMentionable.type === 'current-file'
) {
if (!displayedMentionable.file) return null
return await readTFileContent(displayedMentionable.file, app.vault)
} else if (displayedMentionable.type === 'block') {
const fileContent = await readTFileContent(
displayedMentionable.file,
app.vault,
)
return fileContent
.split('\n')
.slice(
displayedMentionable.startLine - 1,
displayedMentionable.endLine,
)
.join('\n')
}
return null
},
})
const displayImage: MentionableImage | null = useMemo(() => {
return displayedMentionable?.type === 'image' ? displayedMentionable : null
}, [displayedMentionable])
return displayFileContent ? (
<div className="infio-chat-user-input-file-content-preview">
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="markdown"
hasFilename={false}
wrapLines={false}
>
{displayFileContent}
</MemoizedSyntaxHighlighterWrapper>
</div>
) : displayImage ? (
<div className="infio-chat-user-input-file-content-preview">
<img src={displayImage.data} alt={displayImage.name} />
</div>
) : null
}
ChatUserInput.displayName = 'ChatUserInput'
export default ChatUserInput

View File

@ -0,0 +1,30 @@
import { ImageIcon } from 'lucide-react'
export function ImageUploadButton({
onUpload,
}: {
onUpload: (files: File[]) => void
}) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? [])
if (files.length > 0) {
onUpload(files)
}
}
return (
<label className="infio-chat-user-input-submit-button">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="infio-chat-user-input-submit-button-icons">
<ImageIcon size={12} />
</div>
<div>Image</div>
</label>
)
}

View File

@ -0,0 +1,153 @@
import {
InitialConfigType,
InitialEditorStateType,
LexicalComposer,
} from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { EditorRefPlugin } from '@lexical/react/LexicalEditorRefPlugin'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { LexicalEditor, SerializedEditorState } from 'lexical'
import { RefObject, useCallback, useEffect } from 'react'
import { useApp } from '../../../contexts/AppContext'
import { MentionableImage } from '../../../types/mentionable'
import { fuzzySearch } from '../../../utils/fuzzy-search'
import DragDropPaste from './plugins/image/DragDropPastePlugin'
import ImagePastePlugin from './plugins/image/ImagePastePlugin'
import AutoLinkMentionPlugin from './plugins/mention/AutoLinkMentionPlugin'
import { MentionNode } from './plugins/mention/MentionNode'
import MentionPlugin from './plugins/mention/MentionPlugin'
import NoFormatPlugin from './plugins/no-format/NoFormatPlugin'
import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin'
import OnMutationPlugin, {
NodeMutations,
} from './plugins/on-mutation/OnMutationPlugin'
import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
import TemplatePlugin from './plugins/template/TemplatePlugin'
export type LexicalContentEditableProps = {
editorRef: RefObject<LexicalEditor>
contentEditableRef: RefObject<HTMLDivElement>
onChange?: (content: SerializedEditorState) => void
onEnter?: (evt: KeyboardEvent) => void
onFocus?: () => void
onMentionNodeMutation?: (mutations: NodeMutations<MentionNode>) => void
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
initialEditorState?: InitialEditorStateType
autoFocus?: boolean
plugins?: {
onEnter?: {
onVaultChat: () => void
}
templatePopover?: {
anchorElement: HTMLElement | null
}
}
}
export default function LexicalContentEditable({
editorRef,
contentEditableRef,
onChange,
onEnter,
onFocus,
onMentionNodeMutation,
onCreateImageMentionables,
initialEditorState,
autoFocus = false,
plugins,
}: LexicalContentEditableProps) {
const app = useApp()
const initialConfig: InitialConfigType = {
namespace: 'LexicalContentEditable',
theme: {
root: 'infio-chat-lexical-content-editable-root',
paragraph: 'infio-chat-lexical-content-editable-paragraph',
},
nodes: [MentionNode],
editorState: initialEditorState,
onError: (error) => {
console.error(error)
},
}
const searchResultByQuery = useCallback(
(query: string) => fuzzySearch(app, query),
[app],
)
/*
* Using requestAnimationFrame for autoFocus instead of using editor.focus()
* due to known issues with editor.focus() when initialConfig.editorState is set
* See: https://github.com/facebook/lexical/issues/4460
*/
useEffect(() => {
if (autoFocus) {
requestAnimationFrame(() => {
contentEditableRef.current?.focus()
})
}
}, [autoFocus, contentEditableRef])
return (
<LexicalComposer initialConfig={initialConfig}>
{/*
There was two approach to make mentionable node copy and pasteable.
1. use RichTextPlugin and reset text format when paste
- so I implemented NoFormatPlugin to reset text format when paste
2. use PlainTextPlugin and override paste command
- PlainTextPlugin only pastes text, so we need to implement custom paste handler.
- https://github.com/facebook/lexical/discussions/5112
*/}
<RichTextPlugin
contentEditable={
<ContentEditable
className="obsidian-default-textarea"
style={{
background: 'transparent',
}}
onFocus={onFocus}
ref={contentEditableRef}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<MentionPlugin searchResultByQuery={searchResultByQuery} />
<OnChangePlugin
onChange={(editorState) => {
onChange?.(editorState.toJSON())
}}
/>
{onEnter && (
<OnEnterPlugin
onEnter={onEnter}
onVaultChat={plugins?.onEnter?.onVaultChat}
/>
)}
<OnMutationPlugin
nodeClass={MentionNode}
onMutation={(mutations) => {
onMentionNodeMutation?.(mutations)
}}
/>
<EditorRefPlugin editorRef={editorRef} />
<NoFormatPlugin />
<AutoLinkMentionPlugin />
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
<TemplatePlugin />
{plugins?.templatePopover && (
<CreateTemplatePopoverPlugin
anchorElement={plugins.templatePopover.anchorElement}
contentEditableElement={contentEditableRef.current}
/>
)}
</LexicalComposer>
)
}

View File

@ -0,0 +1,319 @@
import { X } from 'lucide-react'
import { PropsWithChildren } from 'react'
import {
Mentionable,
MentionableBlock,
MentionableCurrentFile,
MentionableFile,
MentionableFolder,
MentionableImage,
MentionableUrl,
MentionableVault,
} from '../../../types/mentionable'
import { getMentionableIcon } from './utils/get-metionable-icon'
function BadgeBase({
children,
onDelete,
onClick,
isFocused,
}: PropsWithChildren<{
onDelete: () => void
onClick: () => void
isFocused: boolean
}>) {
return (
<div
className={`infio-chat-user-input-file-badge ${isFocused ? 'infio-chat-user-input-file-badge-focused' : ''}`}
onClick={onClick}
>
{children}
<div
className="infio-chat-user-input-file-badge-delete"
onClick={(evt) => {
evt.stopPropagation()
onDelete()
}}
>
<X size={10} />
</div>
</div>
)
}
function FileBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableFile
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.file.name}</span>
</div>
</BadgeBase>
)
}
function FolderBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableFolder
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.folder.name}</span>
</div>
</BadgeBase>
)
}
function VaultBadge({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableVault
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
{/* TODO: Update style */}
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>Vault</span>
</div>
</BadgeBase>
)
}
function CurrentFileBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableCurrentFile
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return mentionable.file ? (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.file.name}</span>
</div>
<div className="infio-chat-user-input-file-badge-name-block-suffix">
{' (Current File)'}
</div>
</BadgeBase>
) : null
}
function BlockBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableBlock
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name-block-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-block-name-icon"
/>
)}
<span>{mentionable.file.name}</span>
</div>
<div className="infio-chat-user-input-file-badge-name-block-suffix">
{` (${mentionable.startLine}:${mentionable.endLine})`}
</div>
</BadgeBase>
)
}
function UrlBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableUrl
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.url}</span>
</div>
</BadgeBase>
)
}
function ImageBadge({
mentionable,
onDelete,
onClick,
isFocused,
}: {
mentionable: MentionableImage
onDelete: () => void
onClick: () => void
isFocused: boolean
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
<div className="infio-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.name}</span>
</div>
</BadgeBase>
)
}
export default function MentionableBadge({
mentionable,
onDelete,
onClick,
isFocused = false,
}: {
mentionable: Mentionable
onDelete: () => void
onClick: () => void
isFocused?: boolean
}) {
switch (mentionable.type) {
case 'file':
return (
<FileBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'folder':
return (
<FolderBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'vault':
return (
<VaultBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'current-file':
return (
<CurrentFileBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'block':
return (
<BlockBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'url':
return (
<UrlBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
case 'image':
return (
<ImageBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
isFocused={isFocused}
/>
)
}
}

View File

@ -0,0 +1,51 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import { useSettings } from '../../../contexts/SettingsContext'
export function ModelSelect() {
const { settings, setSettings } = useSettings()
const [isOpen, setIsOpen] = useState(false)
const activeModels = settings.activeModels.filter((model) => model.enabled)
return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger className="infio-chat-input-model-select">
<div className="infio-chat-input-model-select__icon">
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
<div className="infio-chat-input-model-select__model-name">
{
activeModels.find(
(option) => option.name === settings.chatModelId,
)?.name
}
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="infio-popover">
<ul>
{activeModels.map((model) => (
<DropdownMenu.Item
key={model.name}
onSelect={() => {
setSettings({
...settings,
chatModelId: model.name,
})
}}
asChild
>
<li>{model.name}</li>
</DropdownMenu.Item>
))}
</ul>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}

View File

@ -0,0 +1,12 @@
import { CornerDownLeftIcon } from 'lucide-react'
export function SubmitButton({ onClick }: { onClick: () => void }) {
return (
<button className="infio-chat-user-input-submit-button" onClick={onClick}>
<div>submit</div>
<div className="infio-chat-user-input-submit-button-icons">
<CornerDownLeftIcon size={12} />
</div>
</button>
)
}

View File

@ -0,0 +1,42 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import {
ArrowBigUp,
ChevronUp,
Command,
CornerDownLeftIcon,
} from 'lucide-react'
import { Platform } from 'obsidian'
export function VaultChatButton({ onClick }: { onClick: () => void }) {
return (
<>
<Tooltip.Provider delayDuration={0}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
className="infio-chat-user-input-vault-button"
onClick={onClick}
>
<div>vault</div>
<div className="infio-chat-user-input-vault-button-icons">
{Platform.isMacOS ? (
<Command size={10} />
) : (
<ChevronUp size={12} />
)}
{/* TODO: Replace with a custom icon */}
{/* <ArrowBigUp size={12} /> */}
<CornerDownLeftIcon size={12} />
</div>
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="infio-tooltip-content" sideOffset={5}>
Chat with your entire vault
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</>
)
}

View File

@ -0,0 +1,34 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { DRAG_DROP_PASTE } from '@lexical/rich-text'
import { COMMAND_PRIORITY_LOW } from 'lexical'
import { useEffect } from 'react'
import { MentionableImage } from '../../../../../types/mentionable'
import { fileToMentionableImage } from '../../../../../utils/image'
export default function DragDropPaste({
onCreateImageMentionables,
}: {
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
}): null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE, // dispatched in RichTextPlugin
(files) => {
; (async () => {
const images = files.filter((file) => file.type.startsWith('image/'))
const mentionableImages = await Promise.all(
images.map(async (image) => await fileToMentionableImage(image)),
)
onCreateImageMentionables?.(mentionableImages)
})()
return true
},
COMMAND_PRIORITY_LOW,
)
}, [editor, onCreateImageMentionables])
return null
}

View File

@ -0,0 +1,42 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { COMMAND_PRIORITY_LOW, PASTE_COMMAND, PasteCommandType } from 'lexical'
import { useEffect } from 'react'
import { MentionableImage } from '../../../../../types/mentionable'
import { fileToMentionableImage } from '../../../../../utils/image'
export default function ImagePastePlugin({
onCreateImageMentionables,
}: {
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
}) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const handlePaste = (event: PasteCommandType) => {
const clipboardData =
event instanceof ClipboardEvent ? event.clipboardData : null
if (!clipboardData) return false
const images = Array.from(clipboardData.files).filter((file) =>
file.type.startsWith('image/'),
)
if (images.length === 0) return false
Promise.all(images.map((image) => fileToMentionableImage(image))).then(
(mentionableImages) => {
onCreateImageMentionables?.(mentionableImages)
},
)
return true
}
return editor.registerCommand(
PASTE_COMMAND,
handlePaste,
COMMAND_PRIORITY_LOW,
)
}, [editor, onCreateImageMentionables])
return null
}

View File

@ -0,0 +1,178 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$createTextNode,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
PASTE_COMMAND,
PasteCommandType,
TextNode,
} from 'lexical'
import { useEffect } from 'react'
import { Mentionable, MentionableUrl } from '../../../../../types/mentionable'
import {
getMentionableName,
serializeMentionable,
} from '../../../../../utils/mentionable'
import { $createMentionNode } from './MentionNode'
const URL_MATCHER =
/^((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/
type URLMatch = {
index: number
length: number
text: string
url: string
}
function findURLs(text: string): URLMatch[] {
const urls: URLMatch[] = []
let lastIndex = 0
for (const word of text.split(' ')) {
if (URL_MATCHER.test(word)) {
urls.push({
index: lastIndex,
length: word.length,
text: word,
url: word.startsWith('http') ? word : `https://${word}`,
// attributes: { rel: 'noreferrer', target: '_blank' }, // Optional link attributes
})
}
lastIndex += word.length + 1 // +1 for space
}
return urls
}
function $textNodeTransform(node: TextNode) {
if (!node.isSimpleText()) {
return
}
const text = node.getTextContent()
// Find only 1st occurrence as transform will be re-run anyway for the rest
// because newly inserted nodes are considered to be dirty
const urlMatches = findURLs(text)
if (urlMatches.length === 0) {
return
}
const urlMatch = urlMatches[0]
// Get the current selection
const selection = $getSelection()
// Check if the selection is a RangeSelection and the cursor is at the end of the URL
if (
$isRangeSelection(selection) &&
selection.anchor.key === node.getKey() &&
selection.focus.key === node.getKey() &&
selection.anchor.offset === urlMatch.index + urlMatch.length &&
selection.focus.offset === urlMatch.index + urlMatch.length
) {
// If the cursor is at the end of the URL, don't transform
return
}
let targetNode
if (urlMatch.index === 0) {
// First text chunk within string, splitting into 2 parts
;[targetNode] = node.splitText(urlMatch.index + urlMatch.length)
} else {
// In the middle of a string
;[, targetNode] = node.splitText(
urlMatch.index,
urlMatch.index + urlMatch.length,
)
}
const mentionable: MentionableUrl = {
type: 'url',
url: urlMatch.url,
}
const mentionNode = $createMentionNode(
getMentionableName(mentionable),
serializeMentionable(mentionable),
)
targetNode.replace(mentionNode)
const spaceNode = $createTextNode(' ')
mentionNode.insertAfter(spaceNode)
spaceNode.select()
}
function $handlePaste(event: PasteCommandType) {
const clipboardData =
event instanceof ClipboardEvent ? event.clipboardData : null
if (!clipboardData) return false
const text = clipboardData.getData('text/plain')
const urlMatches = findURLs(text)
if (urlMatches.length === 0) {
return false
}
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const nodes = []
const addedMentionables: Mentionable[] = []
let lastIndex = 0
urlMatches.forEach((urlMatch) => {
// Add text node for unmatched part
if (urlMatch.index > lastIndex) {
nodes.push($createTextNode(text.slice(lastIndex, urlMatch.index)))
}
const mentionable: MentionableUrl = {
type: 'url',
url: urlMatch.url,
}
// Add mention node
nodes.push(
$createMentionNode(urlMatch.text, serializeMentionable(mentionable)),
)
addedMentionables.push(mentionable)
lastIndex = urlMatch.index + urlMatch.length
// Add space node after mention if next character is not space or end of string
if (lastIndex >= text.length || text[lastIndex] !== ' ') {
nodes.push($createTextNode(' '))
}
})
// Add remaining text if any
if (lastIndex < text.length) {
nodes.push($createTextNode(text.slice(lastIndex)))
}
selection.insertNodes(nodes)
return true
}
export default function AutoLinkMentionPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.registerCommand(PASTE_COMMAND, $handlePaste, COMMAND_PRIORITY_LOW)
editor.registerNodeTransform(TextNode, $textNodeTransform)
}, [editor])
return null
}

View File

@ -0,0 +1,176 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license.
* Original source: https://github.com/facebook/lexical
*
* Modified from the original code
*/
import {
$applyNodeReplacement,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
type EditorConfig,
type LexicalNode,
type NodeKey,
type SerializedTextNode,
type Spread,
TextNode,
} from 'lexical'
import { SerializedMentionable } from '../../../../../types/mentionable'
export const MENTION_NODE_TYPE = 'mention'
export const MENTION_NODE_ATTRIBUTE = 'data-lexical-mention'
export const MENTION_NODE_MENTION_NAME_ATTRIBUTE = 'data-lexical-mention-name'
export const MENTION_NODE_MENTIONABLE_ATTRIBUTE = 'data-lexical-mentionable'
export type SerializedMentionNode = Spread<
{
mentionName: string
mentionable: SerializedMentionable
},
SerializedTextNode
>
function $convertMentionElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const textContent = domNode.textContent
const mentionName =
domNode.getAttribute(MENTION_NODE_MENTION_NAME_ATTRIBUTE) ??
domNode.textContent ??
''
const mentionable = JSON.parse(
domNode.getAttribute(MENTION_NODE_MENTIONABLE_ATTRIBUTE) ?? '{}',
)
if (textContent !== null) {
const node = $createMentionNode(
mentionName,
mentionable as SerializedMentionable,
)
return {
node,
}
}
return null
}
export class MentionNode extends TextNode {
__mentionName: string
__mentionable: SerializedMentionable
static getType(): string {
return MENTION_NODE_TYPE
}
static clone(node: MentionNode): MentionNode {
return new MentionNode(node.__mentionName, node.__mentionable, node.__key)
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
const node = $createMentionNode(
serializedNode.mentionName,
serializedNode.mentionable,
)
node.setTextContent(serializedNode.text)
node.setFormat(serializedNode.format)
node.setDetail(serializedNode.detail)
node.setMode(serializedNode.mode)
node.setStyle(serializedNode.style)
return node
}
constructor(
mentionName: string,
mentionable: SerializedMentionable,
key?: NodeKey,
) {
super(`@${mentionName}`, key)
this.__mentionName = mentionName
this.__mentionable = mentionable
}
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
mentionName: this.__mentionName,
mentionable: this.__mentionable,
type: MENTION_NODE_TYPE,
version: 1,
}
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config)
dom.className = MENTION_NODE_TYPE
return dom
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span')
element.setAttribute(MENTION_NODE_ATTRIBUTE, 'true')
element.setAttribute(
MENTION_NODE_MENTION_NAME_ATTRIBUTE,
this.__mentionName,
)
element.setAttribute(
MENTION_NODE_MENTIONABLE_ATTRIBUTE,
JSON.stringify(this.__mentionable),
)
element.textContent = this.__text
return { element }
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (
!domNode.hasAttribute(MENTION_NODE_ATTRIBUTE) ||
!domNode.hasAttribute(MENTION_NODE_MENTION_NAME_ATTRIBUTE) ||
!domNode.hasAttribute(MENTION_NODE_MENTIONABLE_ATTRIBUTE)
) {
return null
}
return {
conversion: $convertMentionElement,
priority: 1,
}
},
}
}
isTextEntity(): true {
return true
}
canInsertTextBefore(): boolean {
return false
}
canInsertTextAfter(): boolean {
return false
}
getMentionable(): SerializedMentionable {
return this.__mentionable
}
}
export function $createMentionNode(
mentionName: string,
mentionable: SerializedMentionable,
): MentionNode {
const mentionNode = new MentionNode(mentionName, mentionable)
mentionNode.setMode('token').toggleDirectionless()
return $applyNodeReplacement(mentionNode)
}
export function $isMentionNode(
node: LexicalNode | null | undefined,
): node is MentionNode {
return node instanceof MentionNode
}

View File

@ -0,0 +1,273 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license.
* Original source: https://github.com/facebook/lexical
*
* Modified from the original code
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $createTextNode, COMMAND_PRIORITY_NORMAL, TextNode } from 'lexical'
import { useCallback, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Mentionable } from '../../../../../types/mentionable'
import { SearchableMentionable } from '../../../../../utils/fuzzy-search'
import {
getMentionableName,
serializeMentionable,
} from '../../../../../utils/mentionable'
import { getMentionableIcon } from '../../utils/get-metionable-icon'
import { MenuOption, MenuTextMatch } from '../shared/LexicalMenu'
import {
LexicalTypeaheadMenuPlugin,
useBasicTypeaheadTriggerMatch,
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
import { $createMentionNode } from './MentionNode'
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']'
const DocumentMentionsRegex = {
NAME,
PUNCTUATION,
}
const PUNC = DocumentMentionsRegex.PUNCTUATION
const TRIGGERS = ['@'].join('')
// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]'
// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS =
'(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNC +
']|' + // E.g. "-' in "Salier-Hellendag"
')'
const LENGTH_LIMIT = 75
const AtSignMentionsRegex = new RegExp(
`(^|\\s|\\()([${TRIGGERS}]((?:${VALID_CHARS}${VALID_JOINS}){0,${LENGTH_LIMIT}}))$`,
)
// 50 is the longest alias length limit.
const ALIAS_LENGTH_LIMIT = 50
// Regex used to match alias.
const AtSignMentionsRegexAliasRegex = new RegExp(
`(^|\\s|\\()([${TRIGGERS}]((?:${VALID_CHARS}){0,${ALIAS_LENGTH_LIMIT}}))$`,
)
// At most, 20 suggestions are shown in the popup.
const SUGGESTION_LIST_LENGTH_LIMIT = 20
function checkForAtSignMentions(
text: string,
minMatchLength: number,
): MenuTextMatch | null {
let match = AtSignMentionsRegex.exec(text)
if (match === null) {
match = AtSignMentionsRegexAliasRegex.exec(text)
}
if (match !== null) {
// The strategy ignores leading whitespace but we need to know it's
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1]
const matchingString = match[3]
if (matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
}
}
}
return null
}
function getPossibleQueryMatch(text: string): MenuTextMatch | null {
return checkForAtSignMentions(text, 0)
}
class MentionTypeaheadOption extends MenuOption {
name: string
mentionable: Mentionable
icon: React.ReactNode
constructor(result: SearchableMentionable) {
switch (result.type) {
case 'file':
super(result.file.path)
this.name = result.file.name
this.mentionable = result
break
case 'folder':
super(result.folder.path)
this.name = result.folder.name
this.mentionable = result
break
case 'vault':
super('vault')
this.name = 'Vault'
this.mentionable = result
break
}
}
}
function MentionsTypeaheadMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: MentionTypeaheadOption
}) {
let className = 'item'
if (isSelected) {
className += ' selected'
}
const Icon = getMentionableIcon(option.mentionable)
return (
<li
key={option.key}
tabIndex={-1}
className={className}
ref={(el) => option.setRefElement(el)}
role="option"
aria-selected={isSelected}
id={`typeahead-item-${index}`}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
{Icon && <Icon size={14} className="infio-popover-item-icon" />}
<span className="text">{option.name}</span>
</li>
)
}
export default function NewMentionsPlugin({
searchResultByQuery,
}: {
searchResultByQuery: (query: string) => SearchableMentionable[]
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [queryString, setQueryString] = useState<string | null>(null)
const results = useMemo(() => {
if (queryString == null) return []
return searchResultByQuery(queryString)
}, [queryString, searchResultByQuery])
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const options = useMemo(
() =>
results
.map((result) => new MentionTypeaheadOption(result))
.slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
[results],
)
const onSelectOption = useCallback(
(
selectedOption: MentionTypeaheadOption,
nodeToReplace: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const mentionNode = $createMentionNode(
getMentionableName(selectedOption.mentionable),
serializeMentionable(selectedOption.mentionable),
)
if (nodeToReplace) {
nodeToReplace.replace(mentionNode)
}
const spaceNode = $createTextNode(' ')
mentionNode.insertAfter(spaceNode)
spaceNode.select()
closeMenu()
})
},
[editor],
)
const checkForMentionMatch = useCallback(
(text: string) => {
const slashMatch = checkForSlashTriggerMatch(text, editor)
if (slashMatch !== null) {
return null
}
return getPossibleQueryMatch(text)
},
[checkForSlashTriggerMatch, editor],
)
return (
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForMentionMatch}
options={options}
commandPriority={COMMAND_PRIORITY_NORMAL}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && results.length
? createPortal(
<div
className="infio-popover"
style={{
position: 'fixed',
}}
>
<ul>
{options.map((option, i: number) => (
<MentionsTypeaheadMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
)
}

View File

@ -0,0 +1,17 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { TextNode } from 'lexical'
import { useEffect } from 'react'
export default function NoFormatPlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.registerNodeTransform(TextNode, (node) => {
if (node.getFormat() !== 0) {
node.setFormat(0)
}
})
}, [editor])
return null
}

View File

@ -0,0 +1,46 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from 'lexical'
import { Platform } from 'obsidian'
import { useEffect } from 'react'
export default function OnEnterPlugin({
onEnter,
onVaultChat,
}: {
onEnter: (evt: KeyboardEvent) => void
onVaultChat?: () => void
}) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const removeListener = editor.registerCommand(
KEY_ENTER_COMMAND,
(evt: KeyboardEvent) => {
console.log('onEnter', evt)
if (
onVaultChat &&
(Platform.isMacOS ? evt.metaKey : evt.ctrlKey)
) {
evt.preventDefault()
evt.stopPropagation()
onVaultChat()
return true
}
if (evt.shiftKey) {
return false
}
evt.preventDefault()
evt.stopPropagation()
onEnter(evt)
return true
},
COMMAND_PRIORITY_LOW,
)
return () => {
removeListener()
}
}, [editor, onEnter, onVaultChat])
return null
}

View File

@ -0,0 +1,46 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { Klass, LexicalNode, NodeKey, NodeMutation } from 'lexical'
import { useEffect } from 'react'
export type NodeMutations<T> = Map<NodeKey, { mutation: NodeMutation; node: T }>
export default function OnMutationPlugin<T extends LexicalNode>({
nodeClass,
onMutation,
}: {
nodeClass: Klass<T>
onMutation: (mutations: NodeMutations<T>) => void
}) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const removeListener = editor.registerMutationListener(
nodeClass,
(mutatedNodes, payload) => {
const editorState = editor.getEditorState()
const mutations = new Map<
NodeKey,
{ mutation: NodeMutation; node: T }
>()
for (const [key, mutation] of mutatedNodes) {
mutations.set(key, {
mutation,
node:
mutation === 'destroyed'
? (payload.prevEditorState._nodeMap.get(key) as T)
: (editorState._nodeMap.get(key) as T),
})
}
onMutation(mutations)
},
)
return () => {
removeListener()
}
}, [editor, nodeClass, onMutation])
return null
}

View File

@ -0,0 +1,597 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license.
* Original source: https://github.com/facebook/lexical
*
* Modified from the original code
* - Added custom positioning logic for menu placement
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
CommandListenerPriority,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
LexicalCommand,
LexicalEditor,
TextNode,
createCommand,
} from 'lexical'
import {
MutableRefObject,
ReactPortal,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
export type MenuTextMatch = {
leadOffset: number
matchingString: string
replaceableString: string
}
export type MenuResolution = {
match?: MenuTextMatch
getRect: () => DOMRect
}
export const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
export class MenuOption {
key: string
ref?: MutableRefObject<HTMLElement | null>
constructor(key: string) {
this.key = key
this.ref = { current: null }
this.setRefElement = this.setRefElement.bind(this)
}
setRefElement(element: HTMLElement | null) {
this.ref = { current: element }
}
}
export type MenuRenderFn<TOption extends MenuOption> = (
anchorElementRef: MutableRefObject<HTMLElement | null>,
itemProps: {
selectedIndex: number | null
selectOptionAndCleanUp: (option: TOption) => void
setHighlightedIndex: (index: number) => void
options: TOption[]
},
matchingString: string | null,
) => ReactPortal | JSX.Element | null
const scrollIntoViewIfNeeded = (target: HTMLElement) => {
const typeaheadContainerNode = document.getElementById('typeahead-menu')
if (!typeaheadContainerNode) {
return
}
const typeaheadRect = typeaheadContainerNode.getBoundingClientRect()
if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) {
typeaheadContainerNode.scrollIntoView({
block: 'center',
})
}
if (typeaheadRect.top < 0) {
typeaheadContainerNode.scrollIntoView({
block: 'center',
})
}
target.scrollIntoView({ block: 'nearest' })
}
/**
* Walk backwards along user input and forward through entity title to try
* and replace more of the user's text with entity.
*/
function getFullMatchOffset(
documentText: string,
entryText: string,
offset: number,
): number {
let triggerOffset = offset
for (let i = triggerOffset; i <= entryText.length; i++) {
if (documentText.substr(-i) === entryText.substr(0, i)) {
triggerOffset = i
}
}
return triggerOffset
}
/**
* Split Lexical TextNode and return a new TextNode only containing matched text.
* Common use cases include: removing the node, replacing with a new node.
*/
function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return null
}
const anchor = selection.anchor
if (anchor.type !== 'text') {
return null
}
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText()) {
return null
}
const selectionOffset = anchor.offset
const textContent = anchorNode.getTextContent().slice(0, selectionOffset)
const characterOffset = match.replaceableString.length
const queryOffset = getFullMatchOffset(
textContent,
match.matchingString,
characterOffset,
)
const startOffset = selectionOffset - queryOffset
if (startOffset < 0) {
return null
}
let newNode
if (startOffset === 0) {
;[newNode] = anchorNode.splitText(selectionOffset)
} else {
;[, newNode] = anchorNode.splitText(startOffset, selectionOffset)
}
return newNode
}
// Got from https://stackoverflow.com/a/42543908/2013580
export function getScrollParent(
element: HTMLElement,
includeHidden: boolean,
): HTMLElement | HTMLBodyElement {
let style = getComputedStyle(element)
const excludeStaticParent = style.position === 'absolute'
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/
if (style.position === 'fixed') {
return document.body
}
for (
let parent: HTMLElement | null = element;
(parent = parent.parentElement);
) {
style = getComputedStyle(parent)
if (excludeStaticParent && style.position === 'static') {
continue
}
if (
overflowRegex.test(style.overflow + style.overflowY + style.overflowX)
) {
return parent
}
}
return document.body
}
function isTriggerVisibleInNearestScrollContainer(
targetElement: HTMLElement,
containerElement: HTMLElement,
): boolean {
const tRect = targetElement.getBoundingClientRect()
const cRect = containerElement.getBoundingClientRect()
return tRect.top > cRect.top && tRect.top < cRect.bottom
}
// Reposition the menu on scroll, window resize, and element resize.
export function useDynamicPositioning(
resolution: MenuResolution | null,
targetElement: HTMLElement | null,
onReposition: () => void,
onVisibilityChange?: (isInView: boolean) => void,
) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (targetElement != null && resolution != null) {
const rootElement = editor.getRootElement()
const rootScrollParent =
rootElement != null
? getScrollParent(rootElement, false)
: document.body
let ticking = false
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
)
const handleScroll = function () {
if (!ticking) {
window.requestAnimationFrame(function () {
onReposition()
ticking = false
})
ticking = true
}
const isInView = isTriggerVisibleInNearestScrollContainer(
targetElement,
rootScrollParent,
)
if (isInView !== previousIsInView) {
previousIsInView = isInView
if (onVisibilityChange != null) {
onVisibilityChange(isInView)
}
}
}
const resizeObserver = new ResizeObserver(onReposition)
window.addEventListener('resize', onReposition)
document.addEventListener('scroll', handleScroll, {
capture: true,
passive: true,
})
resizeObserver.observe(targetElement)
return () => {
resizeObserver.unobserve(targetElement)
window.removeEventListener('resize', onReposition)
document.removeEventListener('scroll', handleScroll, true)
}
}
}, [targetElement, editor, onVisibilityChange, onReposition, resolution])
}
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
index: number
option: MenuOption
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND')
export function LexicalMenu<TOption extends MenuOption>({
close,
editor,
anchorElementRef,
resolution,
options,
menuRenderFn,
onSelectOption,
shouldSplitNodeWithQuery = false,
commandPriority = COMMAND_PRIORITY_LOW,
}: {
close: () => void
editor: LexicalEditor
anchorElementRef: MutableRefObject<HTMLElement>
resolution: MenuResolution
options: TOption[]
shouldSplitNodeWithQuery?: boolean
menuRenderFn: MenuRenderFn<TOption>
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void
commandPriority?: CommandListenerPriority
}): JSX.Element | null {
const [selectedIndex, setHighlightedIndex] = useState<null | number>(null)
const matchingString = resolution.match?.matchingString
useEffect(() => {
setHighlightedIndex(0)
}, [matchingString])
const selectOptionAndCleanUp = useCallback(
(selectedEntry: TOption) => {
editor.update(() => {
const textNodeContainingQuery =
resolution.match != null && shouldSplitNodeWithQuery
? $splitNodeContainingQuery(resolution.match)
: null
onSelectOption(
selectedEntry,
textNodeContainingQuery,
close,
resolution.match ? resolution.match.matchingString : '',
)
})
},
[editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close],
)
const updateSelectedIndex = useCallback(
(index: number) => {
const rootElem = editor.getRootElement()
if (rootElem !== null) {
rootElem.setAttribute(
'aria-activedescendant',
`typeahead-item-${index}`,
)
setHighlightedIndex(index)
}
},
[editor],
)
useEffect(() => {
return () => {
const rootElem = editor.getRootElement()
if (rootElem !== null) {
rootElem.removeAttribute('aria-activedescendant')
}
}
}, [editor])
useLayoutEffect(() => {
if (options === null) {
setHighlightedIndex(null)
} else if (selectedIndex === null) {
updateSelectedIndex(0)
}
}, [options, selectedIndex, updateSelectedIndex])
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
({ option }) => {
if (option.ref?.current != null) {
scrollIntoViewIfNeeded(option.ref.current)
return true
}
return false
},
commandPriority,
),
)
}, [editor, updateSelectedIndex, commandPriority])
useEffect(() => {
return mergeRegister(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_DOWN_COMMAND,
(payload) => {
const event = payload
if (options?.length && selectedIndex !== null) {
const newSelectedIndex =
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0
updateSelectedIndex(newSelectedIndex)
const option = options[newSelectedIndex]
if (option.ref?.current != null) {
editor.dispatchCommand(
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
{
index: newSelectedIndex,
option,
},
)
}
event.preventDefault()
event.stopImmediatePropagation()
}
return true
},
commandPriority,
),
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_UP_COMMAND,
(payload) => {
const event = payload
if (options?.length && selectedIndex !== null) {
const newSelectedIndex =
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1
updateSelectedIndex(newSelectedIndex)
const option = options[newSelectedIndex]
if (option.ref?.current != null) {
scrollIntoViewIfNeeded(option.ref.current)
}
event.preventDefault()
event.stopImmediatePropagation()
}
return true
},
commandPriority,
),
editor.registerCommand<KeyboardEvent>(
KEY_ESCAPE_COMMAND,
(payload) => {
const event = payload
event.preventDefault()
event.stopImmediatePropagation()
close()
return true
},
commandPriority,
),
editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(payload) => {
const event = payload
if (
options === null ||
selectedIndex === null ||
options[selectedIndex] == null
) {
return false
}
event.preventDefault()
event.stopImmediatePropagation()
selectOptionAndCleanUp(options[selectedIndex])
return true
},
commandPriority,
),
editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (
options === null ||
selectedIndex === null ||
options[selectedIndex] == null
) {
return false
}
if (event !== null) {
event.preventDefault()
event.stopImmediatePropagation()
}
selectOptionAndCleanUp(options[selectedIndex])
return true
},
commandPriority,
),
)
}, [
selectOptionAndCleanUp,
close,
editor,
options,
selectedIndex,
updateSelectedIndex,
commandPriority,
])
const listItemProps = useMemo(
() => ({
options,
selectOptionAndCleanUp,
selectedIndex,
setHighlightedIndex,
}),
[selectOptionAndCleanUp, selectedIndex, options],
)
return menuRenderFn(
anchorElementRef,
listItemProps,
resolution.match ? resolution.match.matchingString : '',
)
}
export function useMenuAnchorRef(
resolution: MenuResolution | null,
setResolution: (r: MenuResolution | null) => void,
className?: string,
parent: HTMLElement = document.body,
shouldIncludePageYOffset__EXPERIMENTAL = true,
): MutableRefObject<HTMLElement> {
const [editor] = useLexicalComposerContext()
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'))
const positionMenu = useCallback(() => {
anchorElementRef.current.style.top = anchorElementRef.current.style.bottom
const rootElement = editor.getRootElement()
const containerDiv = anchorElementRef.current
const menuEle = containerDiv.firstChild as HTMLElement
if (rootElement !== null && resolution !== null) {
const { left, top, width, height } = resolution.getRect()
const anchorHeight = anchorElementRef.current.offsetHeight // use to position under anchor
containerDiv.style.top = `${top +
anchorHeight +
3 +
(shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0)
}px`
containerDiv.style.left = `${left + window.pageXOffset}px`
containerDiv.style.height = `${height}px`
containerDiv.style.width = `${width}px`
if (menuEle !== null) {
menuEle.style.top = `${top}`
const menuRect = menuEle.getBoundingClientRect()
const menuHeight = menuRect.height
const menuWidth = menuRect.width
const rootElementRect = rootElement.getBoundingClientRect()
if (left + menuWidth > rootElementRect.right) {
containerDiv.style.left = `${rootElementRect.right - menuWidth + window.pageXOffset
}px`
}
if (
// If it exceeds the window height, it should always be displayed above, but the original code checks if it doesn't exceed the editor's top as well. So I modified it.
// (top + menuHeight > window.innerHeight ||
// top + menuHeight > rootElementRect.bottom) &&
// top - rootElementRect.top > menuHeight + height
top + menuHeight >
window.innerHeight
) {
containerDiv.style.top = `${top -
menuHeight -
height +
(shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0)
}px`
}
}
if (!containerDiv.isConnected) {
if (className != null) {
containerDiv.className = className
}
containerDiv.setAttribute('aria-label', 'Typeahead menu')
containerDiv.setAttribute('id', 'typeahead-menu')
containerDiv.setAttribute('role', 'listbox')
containerDiv.style.display = 'block'
containerDiv.style.position = 'absolute'
parent.append(containerDiv)
}
anchorElementRef.current = containerDiv
rootElement.setAttribute('aria-controls', 'typeahead-menu')
}
}, [
editor,
resolution,
shouldIncludePageYOffset__EXPERIMENTAL,
className,
parent,
])
useEffect(() => {
const rootElement = editor.getRootElement()
if (resolution !== null) {
positionMenu()
return () => {
if (rootElement !== null) {
rootElement.removeAttribute('aria-controls')
}
const containerDiv = anchorElementRef.current
if (containerDiv?.isConnected) {
containerDiv.remove()
}
}
}
}, [editor, positionMenu, resolution])
const onVisibilityChange = useCallback(
(isInView: boolean) => {
if (resolution !== null) {
if (!isInView) {
setResolution(null)
}
}
},
[resolution, setResolution],
)
useDynamicPositioning(
resolution,
anchorElementRef.current,
positionMenu,
onVisibilityChange,
)
return anchorElementRef
}
export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null

View File

@ -0,0 +1,146 @@
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import * as Dialog from '@radix-ui/react-dialog'
import {
$getSelection,
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import CreateTemplateDialogContent from '../../../CreateTemplateDialog'
export default function CreateTemplatePopoverPlugin({
anchorElement,
contentEditableElement,
}: {
anchorElement: HTMLElement | null
contentEditableElement: HTMLElement | null
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<
BaseSerializedNode[] | null
>(null)
const popoverRef = useRef<HTMLButtonElement>(null)
const getSelectedSerializedNodes = useCallback(():
| BaseSerializedNode[]
| null => {
if (!editor) return null
let selectedNodes: BaseSerializedNode[] | null = null
editor.update(() => {
const selection = $getSelection()
if (!selection) return
selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes
if (selectedNodes.length === 0) return null
})
return selectedNodes
}, [editor])
const updatePopoverPosition = useCallback(() => {
if (!anchorElement || !contentEditableElement) return
const nativeSelection = document.getSelection()
const range = nativeSelection?.getRangeAt(0)
if (!range || range.collapsed) {
setIsPopoverOpen(false)
return
}
if (!contentEditableElement.contains(range.commonAncestorContainer)) {
setIsPopoverOpen(false)
return
}
const rects = Array.from(range.getClientRects())
if (rects.length === 0) {
setIsPopoverOpen(false)
return
}
const anchorRect = anchorElement.getBoundingClientRect()
const idealLeft = rects[rects.length - 1].right - anchorRect.left
const paddingX = 8
const paddingY = 4
const minLeft = (popoverRef.current?.offsetWidth ?? 0) + paddingX
const finalLeft = Math.max(minLeft, idealLeft)
setPopoverStyle({
top: rects[rects.length - 1].bottom - anchorRect.top + paddingY,
left: finalLeft,
transform: 'translate(-100%, 0)',
})
setIsPopoverOpen(true)
}, [anchorElement, contentEditableElement])
useEffect(() => {
const removeSelectionChangeListener = editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updatePopoverPosition()
return false
},
COMMAND_PRIORITY_LOW,
)
return () => {
removeSelectionChangeListener()
}
}, [editor, updatePopoverPosition])
useEffect(() => {
// Update popover position when the content is cleared
// (Selection change event doesn't fire in this case)
if (!isPopoverOpen) return
const removeTextContentChangeListener = editor.registerTextContentListener(
() => {
updatePopoverPosition()
},
)
return () => {
removeTextContentChangeListener()
}
}, [editor, isPopoverOpen, updatePopoverPosition])
useEffect(() => {
if (!contentEditableElement) return
const handleScroll = () => {
updatePopoverPosition()
}
contentEditableElement.addEventListener('scroll', handleScroll)
return () => {
contentEditableElement.removeEventListener('scroll', handleScroll)
}
}, [contentEditableElement, updatePopoverPosition])
return (
<Dialog.Root
modal={false}
open={isDialogOpen}
onOpenChange={(open) => {
if (open) {
setSelectedSerializedNodes(getSelectedSerializedNodes())
}
setIsDialogOpen(open)
setIsPopoverOpen(false)
}}
>
<Dialog.Trigger asChild>
<button
ref={popoverRef}
style={{
position: 'absolute',
visibility: isPopoverOpen ? 'visible' : 'hidden',
...popoverStyle,
}}
>
Create template
</button>
</Dialog.Trigger>
<CreateTemplateDialogContent
selectedSerializedNodes={selectedSerializedNodes}
onClose={() => setIsDialogOpen(false)}
/>
</Dialog.Root>
)
}

View File

@ -0,0 +1,182 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import clsx from 'clsx'
import {
$parseSerializedNode,
COMMAND_PRIORITY_NORMAL,
TextNode,
} from 'lexical'
import { Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useDatabase } from '../../../../../contexts/DatabaseContext'
import { SelectTemplate } from '../../../../../database/schema'
import { MenuOption } from '../shared/LexicalMenu'
import {
LexicalTypeaheadMenuPlugin,
useBasicTypeaheadTriggerMatch,
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
class TemplateTypeaheadOption extends MenuOption {
name: string
template: SelectTemplate
constructor(name: string, template: SelectTemplate) {
super(name)
this.name = name
this.template = template
}
}
function TemplateMenuItem({
index,
isSelected,
onClick,
onDelete,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onDelete: () => void
onMouseEnter: () => void
option: TemplateTypeaheadOption
}) {
return (
<li
key={option.key}
tabIndex={-1}
className={clsx('item', isSelected && 'selected')}
ref={(el) => option.setRefElement(el)}
role="option"
aria-selected={isSelected}
id={`typeahead-item-${index}`}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
<div className="infio-chat-template-menu-item">
<div className="text">{option.name}</div>
<div
onClick={(evt) => {
evt.stopPropagation()
evt.preventDefault()
onDelete()
}}
className="infio-chat-template-menu-item-delete"
>
<Trash2 size={12} />
</div>
</div>
</li>
)
}
export default function TemplatePlugin() {
const [editor] = useLexicalComposerContext()
const { getTemplateManager } = useDatabase()
const [queryString, setQueryString] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<SelectTemplate[]>([])
useEffect(() => {
if (queryString == null) return
getTemplateManager().then((templateManager) =>
templateManager.searchTemplates(queryString).then(setSearchResults),
)
}, [queryString, getTemplateManager])
const options = useMemo(
() =>
searchResults.map(
(result) => new TemplateTypeaheadOption(result.name, result),
),
[searchResults],
)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const onSelectOption = useCallback(
(
selectedOption: TemplateTypeaheadOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const parsedNodes = selectedOption.template.content.nodes.map((node) =>
$parseSerializedNode(node),
)
if (nodeToRemove) {
const parent = nodeToRemove.getParentOrThrow()
parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes)
const lastNode = parsedNodes[parsedNodes.length - 1]
lastNode.selectEnd()
}
closeMenu()
})
},
[editor],
)
const handleDelete = useCallback(
async (option: TemplateTypeaheadOption) => {
await (await getTemplateManager()).deleteTemplate(option.template.id)
if (queryString !== null) {
const updatedResults = await (
await getTemplateManager()
).searchTemplates(queryString)
setSearchResults(updatedResults)
}
},
[getTemplateManager, queryString],
)
return (
<LexicalTypeaheadMenuPlugin<TemplateTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
commandPriority={COMMAND_PRIORITY_NORMAL}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && searchResults.length
? createPortal(
<div
className="infio-popover"
style={{
position: 'fixed',
}}
>
<ul>
{options.map((option, i: number) => (
<TemplateMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onDelete={() => {
handleDelete(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
)
}

View File

@ -0,0 +1,297 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license.
* Original source: https://github.com/facebook/lexical
*
* Modified from the original code
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
CommandListenerPriority,
LexicalCommand,
LexicalEditor,
RangeSelection,
TextNode,
createCommand,
} from 'lexical'
import { startTransition, useCallback, useEffect, useState } from 'react'
import {
LexicalMenu,
MenuOption,
MenuRenderFn,
MenuResolution,
TriggerFn,
useMenuAnchorRef,
} from '../shared/LexicalMenu'
export const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
function getTextUpToAnchor(selection: RangeSelection): string | null {
const anchor = selection.anchor
if (anchor.type !== 'text') {
return null
}
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText()) {
return null
}
const anchorOffset = anchor.offset
return anchorNode.getTextContent().slice(0, anchorOffset)
}
function tryToPositionRange(
leadOffset: number,
range: Range,
editorWindow: Window,
): boolean {
const domSelection = editorWindow.getSelection()
if (domSelection === null || !domSelection.isCollapsed) {
return false
}
const anchorNode = domSelection.anchorNode
const startOffset = leadOffset
const endOffset = domSelection.anchorOffset
if (anchorNode == null || endOffset == null) {
return false
}
try {
range.setStart(anchorNode, startOffset)
range.setEnd(anchorNode, endOffset)
} catch (error) {
return false
}
return true
}
function getQueryTextForSearch(editor: LexicalEditor): string | null {
let text = null
editor.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
text = getTextUpToAnchor(selection)
})
return text
}
function isSelectionOnEntityBoundary(
editor: LexicalEditor,
offset: number,
): boolean {
if (offset !== 0) {
return false
}
return editor.getEditorState().read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const anchor = selection.anchor
const anchorNode = anchor.getNode()
const prevSibling = anchorNode.getPreviousSibling()
return $isTextNode(prevSibling) && prevSibling.isTextEntity()
}
return false
})
}
// Got from https://stackoverflow.com/a/42543908/2013580
export function getScrollParent(
element: HTMLElement,
includeHidden: boolean,
): HTMLElement | HTMLBodyElement {
let style = getComputedStyle(element)
const excludeStaticParent = style.position === 'absolute'
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/
if (style.position === 'fixed') {
return document.body
}
for (
let parent: HTMLElement | null = element;
(parent = parent.parentElement);
) {
style = getComputedStyle(parent)
if (excludeStaticParent && style.position === 'static') {
continue
}
if (
overflowRegex.test(style.overflow + style.overflowY + style.overflowX)
) {
return parent
}
}
return document.body
}
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
index: number
option: MenuOption
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND')
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = '[^' + trigger + PUNCTUATION + '\\s]'
const TypeaheadTriggerRegex = new RegExp(
`(^|\\s|\\()([${trigger}]((?:${validChars}){0,${maxLength}}))$`,
)
const match = TypeaheadTriggerRegex.exec(text)
if (match !== null) {
const maybeLeadingWhitespace = match[1]
const matchingString = match[3]
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
}
}
}
return null
},
[maxLength, minLength, trigger],
)
}
export type TypeaheadMenuPluginProps<TOption extends MenuOption> = {
onQueryChange: (matchingString: string | null) => void
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void
options: TOption[]
menuRenderFn: MenuRenderFn<TOption>
triggerFn: TriggerFn
onOpen?: (resolution: MenuResolution) => void
onClose?: () => void
anchorClassName?: string
commandPriority?: CommandListenerPriority
parent?: HTMLElement
}
export function LexicalTypeaheadMenuPlugin<TOption extends MenuOption>({
options,
onQueryChange,
onSelectOption,
onOpen,
onClose,
menuRenderFn,
triggerFn,
anchorClassName,
commandPriority = COMMAND_PRIORITY_LOW,
parent,
}: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [resolution, setResolution] = useState<MenuResolution | null>(null)
const anchorElementRef = useMenuAnchorRef(
resolution,
setResolution,
anchorClassName,
parent,
)
const closeTypeahead = useCallback(() => {
setResolution(null)
if (onClose != null && resolution !== null) {
onClose()
}
}, [onClose, resolution])
const openTypeahead = useCallback(
(res: MenuResolution) => {
setResolution(res)
if (onOpen != null && resolution === null) {
onOpen(res)
}
},
[onOpen, resolution],
)
useEffect(() => {
const updateListener = () => {
editor.getEditorState().read(() => {
const editorWindow = editor._window ?? window
const range = editorWindow.document.createRange()
const selection = $getSelection()
const text = getQueryTextForSearch(editor)
if (
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
text === null ||
range === null
) {
closeTypeahead()
return
}
const match = triggerFn(text, editor)
onQueryChange(match ? match.matchingString : null)
if (
match !== null &&
!isSelectionOnEntityBoundary(editor, match.leadOffset)
) {
const isRangePositioned = tryToPositionRange(
match.leadOffset,
range,
editorWindow,
)
if (isRangePositioned !== null) {
startTransition(() =>
openTypeahead({
getRect: () => range.getBoundingClientRect(),
match,
}),
)
return
}
}
closeTypeahead()
})
}
const removeUpdateListener = editor.registerUpdateListener(updateListener)
return () => {
removeUpdateListener()
}
}, [
editor,
triggerFn,
onQueryChange,
resolution,
closeTypeahead,
openTypeahead,
])
return resolution === null || editor === null ? null : (
<LexicalMenu
close={closeTypeahead}
resolution={resolution}
editor={editor}
anchorElementRef={anchorElementRef}
options={options}
menuRenderFn={menuRenderFn}
shouldSplitNodeWithQuery={true}
onSelectOption={onSelectOption}
commandPriority={commandPriority}
/>
)
}

View File

@ -0,0 +1,45 @@
import {
SerializedEditorState,
SerializedElementNode,
SerializedTextNode,
} from 'lexical'
import { editorStateToPlainText } from './editor-state-to-plain-text'
describe('editorStateToPlainText', () => {
it('should convert editor state to plain text', () => {
const editorState: SerializedEditorState = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Hello, world!',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
} as SerializedElementNode<SerializedTextNode>,
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
}
const plainText = editorStateToPlainText(editorState)
expect(plainText).toBe('Hello, world!')
})
})

View File

@ -0,0 +1,21 @@
import { SerializedEditorState, SerializedLexicalNode } from 'lexical'
export function editorStateToPlainText(
editorState: SerializedEditorState,
): string {
return lexicalNodeToPlainText(editorState.root)
}
function lexicalNodeToPlainText(node: SerializedLexicalNode): string {
if ('children' in node) {
// Process children recursively and join their results
return (node.children as SerializedLexicalNode[])
.map(lexicalNodeToPlainText)
.join('')
} else if (node.type === 'linebreak') {
return '\n'
} else if ('text' in node && typeof node.text === 'string') {
return node.text
}
return ''
}

View File

@ -0,0 +1,30 @@
import {
FileIcon,
FolderClosedIcon,
FoldersIcon,
ImageIcon,
LinkIcon,
} from 'lucide-react'
import { Mentionable } from '../../../../types/mentionable'
export const getMentionableIcon = (mentionable: Mentionable) => {
switch (mentionable.type) {
case 'file':
return FileIcon
case 'folder':
return FolderClosedIcon
case 'vault':
return FoldersIcon
case 'current-file':
return FileIcon
case 'block':
return FileIcon
case 'url':
return LinkIcon
case 'image':
return ImageIcon
default:
return null
}
}

View File

@ -0,0 +1,271 @@
import { MarkdownView, Plugin } from "obsidian";
import React, { useEffect, useRef, useState } from "react";
import { APPLY_VIEW_TYPE } from "../../constants";
import LLMManager from "../../core/llm/manager";
import { InfioSettings } from "../../types/settings";
import { manualApplyChangesToFile } from "../../utils/apply";
import { removeAITags } from "../../utils/content-filter";
import { PromptGenerator } from "../../utils/prompt-generator";
interface InlineEditProps {
source: string;
secStartLine: number;
secEndLine: number;
plugin: Plugin;
settings: InfioSettings;
}
interface InputAreaProps {
value: string;
onChange: (value: string) => void;
}
const InputArea: React.FC<InputAreaProps> = ({ value, onChange }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
// 组件挂载后自动聚焦到 textarea
textareaRef.current?.focus();
}, []);
return (
<div className="infio-ai-block-input-wrapper">
<textarea
ref={textareaRef}
className="infio-ai-block-content"
placeholder="Enter instruction"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
};
interface ControlAreaProps {
settings: InfioSettings;
onSubmit: () => void;
selectedModel: string;
onModelChange: (model: string) => void;
isSubmitting: boolean;
}
const ControlArea: React.FC<ControlAreaProps> = ({
settings,
onSubmit,
selectedModel,
onModelChange,
isSubmitting,
}) => (
<div className="infio-ai-block-controls">
<select
className="infio-ai-block-model-select"
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
disabled={isSubmitting}
>
{settings.activeModels
.filter((model) => !model.isEmbeddingModel && model.enabled)
.map((model) => (
<option key={model.name} value={model.name}>
{model.name}
</option>
))}
</select>
<button
className="infio-ai-block-submit-button"
onClick={onSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</div>
);
export const InlineEdit: React.FC<InlineEditProps> = ({
source,
secStartLine,
secEndLine,
plugin,
settings,
}) => {
const [instruction, setInstruction] = useState("");
const [selectedModel, setSelectedModel] = useState(settings.chatModelId);
const [isSubmitting, setIsSubmitting] = useState(false);
const llmManager = new LLMManager({
deepseek: settings.deepseekApiKey,
openai: settings.openAIApiKey,
anthropic: settings.anthropicApiKey,
gemini: settings.geminiApiKey,
groq: settings.groqApiKey,
infio: settings.infioApiKey,
});
const promptGenerator = new PromptGenerator(
async () => {
throw new Error("RAG not needed for inline edit");
},
plugin.app,
settings
);
const handleClose = () => {
const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView?.editor) return;
activeView.editor.replaceRange(
"",
{ line: secStartLine, ch: 0 },
{ line: secEndLine + 1, ch: 0 }
);
};
const getActiveContext = async () => {
const activeFile = plugin.app.workspace.getActiveFile();
if (!activeFile) {
console.error("No active file");
return {};
}
const editor = plugin.app.workspace.getActiveViewOfType(MarkdownView)?.editor;
if (!editor) {
console.error("No active editor");
return { activeFile };
}
const selection = editor.getSelection();
if (!selection) {
console.error("No text selected");
return { activeFile, editor };
}
return { activeFile, editor, selection };
};
const parseSmartComposeBlock = (content: string) => {
const match = content.match(/<infio_block[^>]*>([\s\S]*?)<\/infio_block>/);
if (!match) {
return null;
}
const blockContent = match[1].trim();
const attributes = match[0].match(/startLine="(\d+)"/);
const startLine = attributes ? parseInt(attributes[1]) : undefined;
const endLineMatch = match[0].match(/endLine="(\d+)"/);
const endLine = endLineMatch ? parseInt(endLineMatch[1]) : undefined;
return {
startLine,
endLine,
content: blockContent,
};
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
const { activeFile, editor, selection } = await getActiveContext();
if (!activeFile || !editor || !selection) {
setIsSubmitting(false);
return;
}
const chatModel = settings.activeModels.find(
(model) => model.name === selectedModel
);
if (!chatModel) {
setIsSubmitting(false);
throw new Error("Invalid chat model");
}
const from = editor.getCursor("from");
const to = editor.getCursor("to");
const defaultStartLine = from.line + 1;
const defaultEndLine = to.line + 1;
const requestMessages = await promptGenerator.generateEditMessages({
currentFile: activeFile,
selectedContent: selection,
instruction: instruction,
startLine: defaultStartLine,
endLine: defaultEndLine,
});
const response = await llmManager.generateResponse(chatModel, {
model: chatModel.name,
messages: requestMessages,
stream: false,
});
if (!response.choices[0].message.content) {
setIsSubmitting(false);
throw new Error("Empty response from LLM");
}
const parsedBlock = parseSmartComposeBlock(
response.choices[0].message.content
);
const finalContent = parsedBlock?.content || response.choices[0].message.content;
const startLine = parsedBlock?.startLine || defaultStartLine;
const endLine = parsedBlock?.endLine || defaultEndLine;
const updatedContent = await manualApplyChangesToFile(
finalContent,
activeFile,
await plugin.app.vault.read(activeFile),
startLine,
endLine
);
if (!updatedContent) {
console.error("Failed to apply changes");
setIsSubmitting(false);
return;
}
const originalContent = await plugin.app.vault.read(activeFile);
await plugin.app.workspace.getLeaf(true).setViewState({
type: APPLY_VIEW_TYPE,
active: true,
state: {
file: activeFile,
originalContent: removeAITags(originalContent),
newContent: removeAITags(updatedContent),
},
});
} catch (error) {
console.error("Error in inline edit:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="infio-ai-block-container"
id="infio-ai-block-container"
style={{ backgroundColor: 'var(--background-secondary)' }}
>
<InputArea value={instruction} onChange={setInstruction} />
<button className="infio-ai-block-close-button" onClick={handleClose}>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<ControlArea
settings={settings}
onSubmit={handleSubmit}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
isSubmitting={isSubmitting}
/>
</div>
);
};

157
src/constants.ts Normal file
View File

@ -0,0 +1,157 @@
import { CustomLLMModel } from './types/llm/model'
export const CHAT_VIEW_TYPE = 'infio-chat-view'
export const APPLY_VIEW_TYPE = 'infio-apply-view'
export const DEFAULT_MODELS: CustomLLMModel[] = [
{
name: 'claude-3.5-sonnet',
provider: 'anthropic',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'o1-mini',
provider: 'openai',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'o1-preview',
provider: 'openai',
enabled: false,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'gpt-4o',
provider: 'openai',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'gpt-4o-mini',
provider: 'openai',
enabled: false,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'deepseek-chat',
provider: 'deepseek',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'gemini-1.5-pro',
provider: 'google',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'gemini-2.0-flash-exp',
provider: 'google',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'gemini-2.0-flash-thinking-exp-1219',
provider: 'google',
enabled: false,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'llama-3.1-70b-versatile',
provider: 'groq',
enabled: true,
isEmbeddingModel: false,
isBuiltIn: true,
},
{
name: 'text-embedding-3-small',
provider: 'openai',
dimension: 1536,
enabled: true,
isEmbeddingModel: true,
isBuiltIn: true,
},
{
name: 'text-embedding-004',
provider: 'google',
dimension: 768,
enabled: true,
isEmbeddingModel: true,
isBuiltIn: true,
},
{
name: 'nomic-embed-text',
provider: 'ollama',
dimension: 768,
enabled: true,
isEmbeddingModel: true,
isBuiltIn: true,
},
{
name: 'mxbai-embed-large',
provider: 'ollama',
dimension: 1024,
enabled: true,
isEmbeddingModel: true,
isBuiltIn: true,
},
{
name: 'bge-m3',
provider: 'ollama',
dimension: 1024,
enabled: true,
isEmbeddingModel: true,
isBuiltIn: true,
}
]
export const SUPPORT_EMBEDDING_SIMENTION: number[] = [
384,
512,
768,
1024,
1536
]
export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
// Pricing in dollars per million tokens
type ModelPricing = {
input: number
output: number
}
export const OPENAI_PRICES: Record<string, ModelPricing> = {
'gpt-4o': { input: 2.5, output: 10 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
'deepseek-chat': { input: 0.16, output: 0.32 },
}
export const ANTHROPIC_PRICES: Record<string, ModelPricing> = {
'claude-3-5-sonnet-latest': { input: 3, output: 15 },
'claude-3-5-haiku-latest': { input: 1, output: 5 },
}
// Gemini is currently free for low rate limits
export const GEMINI_PRICES: Record<string, ModelPricing> = {
'gemini-1.5-pro': { input: 0, output: 0 },
'gemini-1.5-flash': { input: 0, output: 0 },
}
export const GROQ_PRICES: Record<string, ModelPricing> = {
'llama-3.1-70b-versatile': { input: 0.59, output: 0.79 },
'llama-3.1-8b-instant': { input: 0.05, output: 0.08 },
}
export const PGLITE_DB_PATH = '.infio_vector_db.tar.gz'

View File

@ -0,0 +1,23 @@
import { App } from 'obsidian'
import React from 'react'
// App context
const AppContext = React.createContext<App | undefined>(undefined)
export const AppProvider = ({
children,
app,
}: {
children: React.ReactNode
app: App
}) => {
return <AppContext.Provider value={app}>{children}</AppContext.Provider>
}
export const useApp = () => {
const app = React.useContext(AppContext)
if (!app) {
throw new Error('useApp must be used within an AppProvider')
}
return app
}

View File

@ -0,0 +1,46 @@
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react'
import { useApp } from './AppContext'
type DarkModeContextType = {
isDarkMode: boolean
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(
undefined,
)
export function DarkModeProvider({ children }: { children: ReactNode }) {
const [isDarkMode, setIsDarkMode] = useState(false)
const app = useApp()
useEffect(() => {
const handleDarkMode = () => {
setIsDarkMode(document.body.classList.contains('theme-dark'))
}
handleDarkMode()
app.workspace.on('css-change', handleDarkMode)
return () => app.workspace.off('css-change', handleDarkMode)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<DarkModeContext.Provider value={{ isDarkMode }}>
{children}
</DarkModeContext.Provider>
)
}
export function useDarkModeContext() {
const context = useContext(DarkModeContext)
if (context === undefined) {
throw new Error('useDarkModeContext must be used within a DarkModeProvider')
}
return context
}

View File

@ -0,0 +1,58 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import { DBManager } from '../database/database-manager'
import { TemplateManager } from '../database/modules/template/template-manager'
import { VectorManager } from '../database/modules/vector/vector-manager'
type DatabaseContextType = {
getDatabaseManager: () => Promise<DBManager>
getVectorManager: () => Promise<VectorManager>
getTemplateManager: () => Promise<TemplateManager>
}
const DatabaseContext = createContext<DatabaseContextType | null>(null)
export function DatabaseProvider({
children,
getDatabaseManager,
}: {
children: React.ReactNode
getDatabaseManager: () => Promise<DBManager>
}) {
const getVectorManager = useCallback(async () => {
return (await getDatabaseManager()).getVectorManager()
}, [getDatabaseManager])
const getTemplateManager = useCallback(async () => {
return (await getDatabaseManager()).getTemplateManager()
}, [getDatabaseManager])
useEffect(() => {
// start initialization of dbManager in the background
void getDatabaseManager()
}, [getDatabaseManager])
const value = useMemo(() => {
return { getDatabaseManager, getVectorManager, getTemplateManager }
}, [getDatabaseManager, getVectorManager, getTemplateManager])
return (
<DatabaseContext.Provider value={value}>
{children}
</DatabaseContext.Provider>
)
}
export function useDatabase(): DatabaseContextType {
const context = useContext(DatabaseContext)
if (!context) {
throw new Error('useDatabase must be used within a DatabaseProvider')
}
return context
}

View File

@ -0,0 +1,27 @@
import React, { createContext, useContext } from 'react'
const DialogContext = createContext<HTMLElement | null>(null)
export function DialogProvider({
children,
container,
}: {
children: React.ReactNode
container: HTMLElement | null
}) {
return (
<DialogContext.Provider value={container}>
{children}
</DialogContext.Provider>
)
}
export function useDialogContainer() {
const context = useContext(DialogContext)
if (!context) {
throw new Error(
'useDialogContainer must be used within a DialogContainerProvider',
)
}
return context
}

135
src/contexts/LLMContext.tsx Normal file
View File

@ -0,0 +1,135 @@
import {
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import LLMManager from '../core/llm/manager'
import { CustomLLMModel } from '../types/llm/model'
import {
LLMOptions,
LLMRequestNonStreaming,
LLMRequestStreaming,
} from '../types/llm/request'
import {
LLMResponseNonStreaming,
LLMResponseStreaming,
} from '../types/llm/response'
import { useSettings } from './SettingsContext'
export type LLMContextType = {
generateResponse: (
model: CustomLLMModel,
request: LLMRequestNonStreaming,
options?: LLMOptions,
) => Promise<LLMResponseNonStreaming>
streamResponse: (
model: CustomLLMModel,
request: LLMRequestStreaming,
options?: LLMOptions,
) => Promise<AsyncIterable<LLMResponseStreaming>>
chatModel: CustomLLMModel
applyModel: CustomLLMModel
}
const LLMContext = createContext<LLMContextType | null>(null)
export function LLMProvider({ children }: PropsWithChildren) {
const [llmManager, setLLMManager] = useState<LLMManager | null>(null)
const { settings } = useSettings()
const chatModel = useMemo((): CustomLLMModel => {
const model = settings.activeModels.find(
(option) => option.name === settings.chatModelId,
)
if (!model) {
throw new Error('Invalid chat model ID')
}
return model
}, [settings])
const applyModel = useMemo((): CustomLLMModel => {
const model = settings.activeModels.find(
(option) => option.name === settings.applyModelId,
)
if (!model) {
throw new Error('Invalid apply model ID')
}
if (model.provider === 'ollama') {
return {
provider: 'ollama',
baseURL: settings.ollamaApplyModel.baseUrl,
model: settings.ollamaApplyModel.model,
}
}
return model
}, [settings])
useEffect(() => {
const manager = new LLMManager({
deepseek: settings.deepseekApiKey,
openai: settings.openAIApiKey,
anthropic: settings.anthropicApiKey,
gemini: settings.geminiApiKey,
groq: settings.groqApiKey,
infio: settings.infioApiKey,
})
setLLMManager(manager)
}, [
settings.deepseekApiKey,
settings.openAIApiKey,
settings.anthropicApiKey,
settings.geminiApiKey,
settings.groqApiKey,
settings.infioApiKey,
])
const generateResponse = useCallback(
async (
model: CustomLLMModel,
request: LLMRequestNonStreaming,
options?: LLMOptions,
) => {
if (!llmManager) {
throw new Error('LLMManager is not initialized')
}
return await llmManager.generateResponse(model, request, options)
},
[llmManager],
)
const streamResponse = useCallback(
async (
model: CustomLLMModel,
request: LLMRequestStreaming,
options?: LLMOptions,
) => {
if (!llmManager) {
throw new Error('LLMManager is not initialized')
}
return await llmManager.streamResponse(model, request, options)
},
[llmManager],
)
return (
<LLMContext.Provider
value={{ generateResponse, streamResponse, chatModel, applyModel }}
>
{children}
</LLMContext.Provider>
)
}
export function useLLM() {
const context = useContext(LLMContext)
if (!context) {
throw new Error('useLLM must be used within an LLMProvider')
}
return context
}

View File

@ -0,0 +1,39 @@
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useMemo,
} from 'react'
import { RAGEngine } from '../core/rag/rag-engine'
export type RAGContextType = {
getRAGEngine: () => Promise<RAGEngine>
}
const RAGContext = createContext<RAGContextType | null>(null)
export function RAGProvider({
getRAGEngine,
children,
}: PropsWithChildren<{ getRAGEngine: () => Promise<RAGEngine> }>) {
useEffect(() => {
// start initialization of ragEngine in the background
void getRAGEngine()
}, [getRAGEngine])
const value = useMemo(() => {
return { getRAGEngine }
}, [getRAGEngine])
return <RAGContext.Provider value={value}>{children}</RAGContext.Provider>
}
export function useRAG() {
const context = useContext(RAGContext)
if (!context) {
throw new Error('useRAG must be used within a RAGProvider')
}
return context
}

View File

@ -0,0 +1,58 @@
import React, { useEffect, useMemo, useState } from 'react'
import { InfioSettings } from '../types/settings'
type SettingsContextType = {
settings: InfioSettings
setSettings: (newSettings: InfioSettings) => void
}
// Settings context
const SettingsContext = React.createContext<SettingsContextType | undefined>(
undefined,
)
export const SettingsProvider = ({
children,
settings: initialSettings,
setSettings,
addSettingsChangeListener,
}: {
children: React.ReactNode
settings: InfioSettings
setSettings: (newSettings: InfioSettings) => void
addSettingsChangeListener: (
listener: (newSettings: InfioSettings) => void,
) => () => void
}) => {
const [settingsCached, setSettingsCached] = useState(initialSettings)
useEffect(() => {
const removeListener = addSettingsChangeListener((newSettings) => {
setSettingsCached(newSettings)
})
return () => {
removeListener()
}
}, [addSettingsChangeListener, setSettings])
const value = useMemo(
() => ({ settings: settingsCached, setSettings }),
[settingsCached, setSettings],
)
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
)
}
export const useSettings = () => {
const settings = React.useContext(SettingsContext)
if (!settings) {
throw new Error('useSettings must be used within a SettingsProvider')
}
return settings
}

View File

@ -0,0 +1,95 @@
import { generateRandomString } from "./utils";
const UNIQUE_CURSOR = `${generateRandomString(16)}`;
const HEADER_REGEX = `^#+\\s.*${UNIQUE_CURSOR}.*$`;
const UNORDERED_LIST_REGEX = `^\\s*(-|\\*)\\s.*${UNIQUE_CURSOR}.*$`;
const TASK_LIST_REGEX = `^\\s*(-|[0-9]+\\.) +\\[.\\]\\s.*${UNIQUE_CURSOR}.*$`;
const BLOCK_QUOTES_REGEX = `^\\s*>.*${UNIQUE_CURSOR}.*$`;
const NUMBERED_LIST_REGEX = `^\\s*\\d+\\.\\s.*${UNIQUE_CURSOR}.*$`
const MATH_BLOCK_REGEX = /\$\$[\s\S]*?\$\$/g;
const INLINE_MATH_BLOCK_REGEX = /\$[\s\S]*?\$/g;
const CODE_BLOCK_REGEX = /```[\s\S]*?```/g;
const INLINE_CODE_BLOCK_REGEX = /`.*`/g;
enum Context {
Text = "Text",
Heading = "Heading",
BlockQuotes = "BlockQuotes",
UnorderedList = "UnorderedList",
NumberedList = "NumberedList",
CodeBlock = "CodeBlock",
MathBlock = "MathBlock",
TaskList = "TaskList",
}
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Context {
export function values(): Array<Context> {
return Object.values(Context).filter(
(value) => typeof value === "string"
) as Array<Context>;
}
export function getContext(prefix: string, suffix: string): Context {
if (new RegExp(HEADER_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.Heading;
}
if (new RegExp(BLOCK_QUOTES_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.BlockQuotes;
}
if (new RegExp(TASK_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.TaskList;
}
if (
isCursorInRegexBlock(prefix, suffix, MATH_BLOCK_REGEX) ||
isCursorInRegexBlock(prefix, suffix, INLINE_MATH_BLOCK_REGEX)
) {
return Context.MathBlock;
}
if (isCursorInRegexBlock(prefix, suffix, CODE_BLOCK_REGEX) || isCursorInRegexBlock(prefix, suffix, INLINE_CODE_BLOCK_REGEX)) {
return Context.CodeBlock;
}
if (new RegExp(NUMBERED_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.NumberedList;
}
if (new RegExp(UNORDERED_LIST_REGEX, "gm").test(prefix + UNIQUE_CURSOR + suffix)) {
return Context.UnorderedList;
}
return Context.Text;
}
export function get(value: string) {
for (const context of Context.values()) {
if (value === context) {
return context;
}
}
return undefined;
}
}
function isCursorInRegexBlock(
prefix: string,
suffix: string,
regex: RegExp
): boolean {
const text = prefix + UNIQUE_CURSOR + suffix;
const codeBlocks = extractBlocks(text, regex);
for (const block of codeBlocks) {
if (block.includes(UNIQUE_CURSOR)) {
return true;
}
}
return false;
}
function extractBlocks(text: string, regex: RegExp) {
const codeBlocks = text.match(regex);
return codeBlocks ? codeBlocks.map((block) => block.trim()) : [];
}
export default Context;

View File

@ -0,0 +1,273 @@
import * as Handlebars from "handlebars";
import { err, ok, Result } from "neverthrow";
import { FewShotExample } from "../../settings/versions";
import { CustomLLMModel } from "../../types/llm/model";
import { RequestMessage } from '../../types/llm/request';
import { InfioSettings } from "../../types/settings";
import LLMManager from '../llm/manager';
import Context from "./context-detection";
import RemoveCodeIndicators from "./post-processors/remove-code-indicators";
import RemoveMathIndicators from "./post-processors/remove-math-indicators";
import RemoveOverlap from "./post-processors/remove-overlap";
import RemoveWhitespace from "./post-processors/remove-whitespace";
import DataViewRemover from "./pre-processors/data-view-remover";
import LengthLimiter from "./pre-processors/length-limiter";
import {
AutocompleteService,
ChatMessage,
PostProcessor,
PreProcessor,
UserMessageFormatter,
UserMessageFormattingInputs
} from "./types";
class LLMClient {
private llm: LLMManager;
private model: CustomLLMModel;
constructor(llm: LLMManager, model: CustomLLMModel) {
this.llm = llm;
this.model = model;
}
async queryChatModel(messages: RequestMessage[]): Promise<Result<string, Error>> {
const data = await this.llm.generateResponse(this.model, {
model: this.model.name,
messages: messages,
stream: false,
})
return ok(data.choices[0].message.content);
}
}
class AutoComplete implements AutocompleteService {
private readonly client: LLMClient;
private readonly systemMessage: string;
private readonly userMessageFormatter: UserMessageFormatter;
private readonly removePreAnswerGenerationRegex: string;
private readonly preProcessors: PreProcessor[];
private readonly postProcessors: PostProcessor[];
private readonly fewShotExamples: FewShotExample[];
private debugMode: boolean;
private constructor(
client: LLMClient,
systemMessage: string,
userMessageFormatter: UserMessageFormatter,
removePreAnswerGenerationRegex: string,
preProcessors: PreProcessor[],
postProcessors: PostProcessor[],
fewShotExamples: FewShotExample[],
debugMode: boolean,
) {
this.client = client;
this.systemMessage = systemMessage;
this.userMessageFormatter = userMessageFormatter;
this.removePreAnswerGenerationRegex = removePreAnswerGenerationRegex;
this.preProcessors = preProcessors;
this.postProcessors = postProcessors;
this.fewShotExamples = fewShotExamples;
this.debugMode = debugMode;
}
public static fromSettings(settings: InfioSettings): AutocompleteService {
const formatter = Handlebars.compile<UserMessageFormattingInputs>(
settings.userMessageTemplate,
{ noEscape: true, strict: true }
);
const preProcessors: PreProcessor[] = [];
if (settings.dontIncludeDataviews) {
preProcessors.push(new DataViewRemover());
}
preProcessors.push(
new LengthLimiter(
settings.maxPrefixCharLimit,
settings.maxSuffixCharLimit
)
);
const postProcessors: PostProcessor[] = [];
if (settings.removeDuplicateMathBlockIndicator) {
postProcessors.push(new RemoveMathIndicators());
}
if (settings.removeDuplicateCodeBlockIndicator) {
postProcessors.push(new RemoveCodeIndicators());
}
postProcessors.push(new RemoveOverlap());
postProcessors.push(new RemoveWhitespace());
const llm_manager = new LLMManager({
deepseek: settings.deepseekApiKey,
openai: settings.openAIApiKey,
anthropic: settings.anthropicApiKey,
gemini: settings.geminiApiKey,
groq: settings.groqApiKey,
infio: settings.infioApiKey,
})
const model: CustomLLMModel = settings.activeModels.find(
(option) => option.name === settings.chatModelId,
)
const llm = new LLMClient(llm_manager, model);
return new AutoComplete(
llm,
settings.systemMessage,
formatter,
settings.chainOfThoughRemovalRegex,
preProcessors,
postProcessors,
settings.fewShotExamples,
settings.debugMode,
);
}
async fetchPredictions(
prefix: string,
suffix: string
): Promise<Result<string, Error>> {
const context: Context = Context.getContext(prefix, suffix);
for (const preProcessor of this.preProcessors) {
if (preProcessor.removesCursor(prefix, suffix)) {
return ok("");
}
({ prefix, suffix } = preProcessor.process(
prefix,
suffix,
context
));
}
const examples = this.fewShotExamples.filter(
(example) => example.context === context
);
const fewShotExamplesChatMessages =
fewShotExamplesToChatMessages(examples);
const messages: RequestMessage[] = [
{
content: this.getSystemMessageFor(context),
role: "system"
},
...fewShotExamplesChatMessages,
{
role: "user",
content: this.userMessageFormatter({
suffix,
prefix,
}),
},
];
if (this.debugMode) {
console.log("Copilot messages send:\n", messages);
}
let result = await this.client.queryChatModel(messages);
if (this.debugMode && result.isOk()) {
console.log("Copilot response:\n", result.value);
}
result = this.extractAnswerFromChainOfThoughts(result);
for (const postProcessor of this.postProcessors) {
result = result.map((r) => postProcessor.process(prefix, suffix, r, context));
}
result = this.checkAgainstGuardRails(result);
return result;
}
private getSystemMessageFor(context: Context): string {
if (context === Context.Text) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a paragraph. Your answer must complete this paragraph or sentence in a way that fits the surrounding text without overlapping with it. It must be in the same language as the paragraph.";
}
if (context === Context.Heading) {
return this.systemMessage + "\n\n" + "The <mask/> is located in the Markdown heading. Your answer must complete this title in a way that fits the content of this paragraph and be in the same language as the paragraph.";
}
if (context === Context.BlockQuotes) {
return this.systemMessage + "\n\n" + "The <mask/> is located within a quote. Your answer must complete this quote in a way that fits the context of the paragraph.";
}
if (context === Context.UnorderedList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in an unordered list. Your answer must include one or more list items that fit with the surrounding list without overlapping with it.";
}
if (context === Context.NumberedList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a numbered list. Your answer must include one or more list items that fit the sequence and context of the surrounding list without overlapping with it.";
}
if (context === Context.CodeBlock) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a code block. Your answer must complete this code block in the same programming language and support the surrounding code and text outside of the code block.";
}
if (context === Context.MathBlock) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a math block. Your answer must only contain LaTeX code that captures the math discussed in the surrounding text. No text or explaination only LaTex math code.";
}
if (context === Context.TaskList) {
return this.systemMessage + "\n\n" + "The <mask/> is located in a task list. Your answer must include one or more (sub)tasks that are logical given the other tasks and the surrounding text.";
}
return this.systemMessage;
}
private extractAnswerFromChainOfThoughts(
result: Result<string, Error>
): Result<string, Error> {
if (result.isErr()) {
return result;
}
const chainOfThoughts = result.value;
const regex = new RegExp(this.removePreAnswerGenerationRegex, "gm");
const match = regex.exec(chainOfThoughts);
if (match === null) {
return err(new Error("No match found"));
}
return ok(chainOfThoughts.replace(regex, ""));
}
private checkAgainstGuardRails(
result: Result<string, Error>
): Result<string, Error> {
if (result.isErr()) {
return result;
}
if (result.value.length === 0) {
return err(new Error("Empty result"));
}
if (result.value.contains("<mask/>")) {
return err(new Error("Mask in result"));
}
return result;
}
}
function fewShotExamplesToChatMessages(
examples: FewShotExample[]
): ChatMessage[] {
return examples
.map((example): ChatMessage[] => {
return [
{
role: "user",
content: example.input,
},
{
role: "assistant",
content: example.answer,
},
];
})
.flat();
}
export default AutoComplete;

View File

@ -0,0 +1,22 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveCodeIndicators implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.CodeBlock) {
completion = completion.replace(/```[a-zA-z]+[ \t]*\n?/g, "");
completion = completion.replace(/\n?```[ \t]*\n?/g, "");
completion = completion.replace(/`/g, "");
}
return completion;
}
}
export default RemoveCodeIndicators;

View File

@ -0,0 +1,20 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveMathIndicators implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.MathBlock) {
completion = completion.replace(/\n?\$\$\n?/g, "");
completion = completion.replace(/\$/g, "");
}
return completion;
}
}
export default RemoveMathIndicators;

View File

@ -0,0 +1,96 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveOverlap implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
completion = removeWordOverlapPrefix(prefix, completion);
completion = removeWordOverlapSuffix(completion, suffix);
completion = removeWhiteSpaceOverlapPrefix(suffix, completion);
completion = removeWhiteSpaceOverlapSuffix(completion, suffix);
return completion;
}
}
function removeWhiteSpaceOverlapPrefix(prefix: string, completion: string): string {
let prefixIdx = prefix.length - 1;
while (completion.length > 0 && completion[0] === prefix[prefixIdx]) {
completion = completion.slice(1);
prefixIdx--;
}
return completion;
}
function removeWhiteSpaceOverlapSuffix(completion: string, suffix: string): string {
let suffixIdx = 0;
while (completion.length > 0 && completion[completion.length - 1] === suffix[suffixIdx]) {
completion = completion.slice(0, -1);
suffixIdx++;
}
return completion;
}
function removeWordOverlapPrefix(prefix: string, completion: string): string {
const rightTrimmed = completion.trimStart();
const startIdxOfEachWord = startLocationOfEachWord(prefix);
while (startIdxOfEachWord.length > 0) {
const idx = startIdxOfEachWord.pop();
const leftSubstring = prefix.slice(idx);
if (rightTrimmed.startsWith(leftSubstring)) {
return rightTrimmed.replace(leftSubstring, "");
}
}
return completion;
}
function removeWordOverlapSuffix(completion: string, suffix: string): string {
const suffixTrimmed = removeLeadingWhiteSpace(suffix);
const startIdxOfEachWord = startLocationOfEachWord(completion);
while (startIdxOfEachWord.length > 0) {
const idx = startIdxOfEachWord.pop();
const suffixSubstring = completion.slice(idx);
if (suffixTrimmed.startsWith(suffixSubstring)) {
return completion.replace(suffixSubstring, "");
}
}
return completion;
}
function removeLeadingWhiteSpace(completion: string): string {
return completion.replace(/^[ \t\f\r\v]+/, "");
}
function startLocationOfEachWord(text: string): number[] {
const locations: number[] = [];
if (text.length > 0 && !isWhiteSpaceChar(text[0])) {
locations.push(0);
}
for (let i = 1; i < text.length; i++) {
if (isWhiteSpaceChar(text[i - 1]) && !isWhiteSpaceChar(text[i])) {
locations.push(i);
}
}
return locations;
}
function isWhiteSpaceChar(char: string | undefined): boolean {
return char !== undefined && char.match(/\s/) !== null;
}
export default RemoveOverlap;

View File

@ -0,0 +1,24 @@
import Context from "../context-detection";
import { PostProcessor } from "../types";
class RemoveWhitespace implements PostProcessor {
process(
prefix: string,
suffix: string,
completion: string,
context: Context
): string {
if (context === Context.Text || context === Context.Heading || context === Context.MathBlock || context === Context.TaskList || context === Context.NumberedList || context === Context.UnorderedList) {
if (prefix.endsWith(" ") || suffix.endsWith("\n")) {
completion = completion.trimStart();
}
if (suffix.startsWith(" ")) {
completion = completion.trimEnd();
}
}
return completion;
}
}
export default RemoveWhitespace;

View File

@ -0,0 +1,34 @@
import { generateRandomString } from "../utils";
import Context from "../context-detection";
import { PrefixAndSuffix, PreProcessor } from "../types";
const DATA_VIEW_REGEX = /```dataview(js){0,1}(.|\n)*?```/gm;
const UNIQUE_CURSOR = `${generateRandomString(16)}`;
class DataViewRemover implements PreProcessor {
process(prefix: string, suffix: string, context: Context): PrefixAndSuffix {
let text = prefix + UNIQUE_CURSOR + suffix;
text = text.replace(DATA_VIEW_REGEX, "");
const [prefixNew, suffixNew] = text.split(UNIQUE_CURSOR);
return { prefix: prefixNew, suffix: suffixNew };
}
removesCursor(prefix: string, suffix: string): boolean {
const text = prefix + UNIQUE_CURSOR + suffix;
const dataviewAreasWithCursor = text
.match(DATA_VIEW_REGEX)
?.filter((dataviewArea) => dataviewArea.includes(UNIQUE_CURSOR));
if (
dataviewAreasWithCursor !== undefined &&
dataviewAreasWithCursor.length > 0
) {
return true;
}
return false;
}
}
export default DataViewRemover;

View File

@ -0,0 +1,24 @@
import Context from "../context-detection";
import { PrefixAndSuffix, PreProcessor } from "../types";
class LengthLimiter implements PreProcessor {
private readonly maxPrefixChars: number;
private readonly maxSuffixChars: number;
constructor(maxPrefixChars: number, maxSuffixChars: number) {
this.maxPrefixChars = maxPrefixChars;
this.maxSuffixChars = maxSuffixChars;
}
process(prefix: string, suffix: string, context: Context): PrefixAndSuffix {
prefix = prefix.slice(-this.maxPrefixChars);
suffix = suffix.slice(0, this.maxSuffixChars);
return { prefix, suffix };
}
removesCursor(prefix: string, suffix: string): boolean {
return false;
}
}
export default LengthLimiter;

View File

@ -0,0 +1,35 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import State from "./state";
class DisabledFileSpecificState extends State {
getStatusBarText(): string {
return "Disabled for this file";
}
handleSettingChanged(settings: InfioSettings) {
if (!this.context.settings.autocompleteEnabled) {
this.context.transitionToDisabledManualState();
}
if (!this.context.isCurrentFilePathIgnored() || !this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToIdleState();
}
}
handleFileChange(file: TFile): void {
if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
return;
}
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
} else {
this.context.transitionToDisabledManualState();
}
}
}
export default DisabledFileSpecificState;

View File

@ -0,0 +1,25 @@
import { InfioSettings } from "../../../types/settings";
import { checkForErrors } from "../../../utils/auto-complete";
import State from "./state";
class DisabledInvalidSettingsState extends State {
getStatusBarText(): string {
return "Disabled invalid settings";
}
handleSettingChanged(settings: InfioSettings) {
const settingErrors = checkForErrors(settings);
if (settingErrors.size > 0) {
return
}
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
} else {
this.context.transitionToDisabledManualState();
}
}
}
export default DisabledInvalidSettingsState;

View File

@ -0,0 +1,21 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import State from "./state";
class DisabledManualState extends State {
getStatusBarText(): string {
return "Disabled";
}
handleSettingChanged(settings: InfioSettings): void {
if (this.context.settings.autocompleteEnabled) {
this.context.transitionToIdleState();
}
}
handleFileChange(file: TFile): void { }
}
export default DisabledManualState;

View File

@ -0,0 +1,46 @@
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class IdleState extends State {
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
!documentChanges.isDocInFocus()
|| !documentChanges.hasDocChanged()
|| documentChanges.hasUserDeleted()
|| documentChanges.hasMultipleCursors()
|| documentChanges.hasSelection()
|| documentChanges.hasUserUndone()
|| documentChanges.hasUserRedone()
) {
return;
}
const cachedSuggestion = this.context.getCachedSuggestionFor(documentChanges.getPrefix(), documentChanges.getSuffix());
const isThereCachedSuggestion = cachedSuggestion !== undefined && cachedSuggestion.trim().length > 0;
if (this.context.settings.cacheSuggestions && isThereCachedSuggestion) {
this.context.transitionToSuggestingState(cachedSuggestion, documentChanges.getPrefix(), documentChanges.getSuffix());
return;
}
if (this.context.containsTriggerCharacters(documentChanges)) {
this.context.transitionToQueuedState(documentChanges.getPrefix(), documentChanges.getSuffix());
}
}
handlePredictCommand(prefix: string, suffix: string): void {
this.context.transitionToPredictingState(prefix, suffix);
}
getStatusBarText(): string {
return "Idle";
}
}
export default IdleState;

View File

@ -0,0 +1,38 @@
import { TFile } from "obsidian";
import { InfioSettings } from "../../../types/settings";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import { EventHandler } from "./types";
class InitState implements EventHandler {
async handleDocumentChange(documentChanges: DocumentChanges): Promise<void> { }
handleSettingChanged(settings: InfioSettings): void { }
handleAcceptKeyPressed(): boolean {
return false;
}
handlePartialAcceptKeyPressed(): boolean {
return false;
}
handleCancelKeyPressed(): boolean {
return false;
}
handlePredictCommand(): void { }
handleAcceptCommand(): void { }
getStatusBarText(): string {
return "Initializing...";
}
handleFileChange(file: TFile): void {
}
}
export default InitState;

View File

@ -0,0 +1,94 @@
import { Notice } from "obsidian";
import Context from "../context-detection";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class PredictingState extends State {
private predictionPromise: Promise<void> | null = null;
private isStillNeeded = true;
private readonly prefix: string;
private readonly suffix: string;
constructor(context: EventListener, prefix: string, suffix: string) {
super(context);
this.prefix = prefix;
this.suffix = suffix;
}
static createAndStartPredicting(
context: EventListener,
prefix: string,
suffix: string
): PredictingState {
const predictingState = new PredictingState(context, prefix, suffix);
predictingState.startPredicting();
context.setContext(Context.getContext(prefix, suffix));
return predictingState;
}
handleCancelKeyPressed(): boolean {
this.cancelPrediction();
return true;
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
documentChanges.hasCursorMoved() ||
documentChanges.hasUserTyped() ||
documentChanges.hasUserDeleted() ||
documentChanges.isTextAdded()
) {
this.cancelPrediction();
}
}
private cancelPrediction(): void {
this.isStillNeeded = false;
this.context.transitionToIdleState();
}
startPredicting(): void {
this.predictionPromise = this.predict();
}
private async predict(): Promise<void> {
const result =
await this.context.autocomplete?.fetchPredictions(
this.prefix,
this.suffix
);
if (!this.isStillNeeded) {
return;
}
if (result.isErr()) {
new Notice(
`Copilot: Something went wrong cannot make a prediction. Full error is available in the dev console. Please check your settings. `
);
console.error(result.error);
this.context.transitionToIdleState();
}
const prediction = result.unwrapOr("");
if (prediction === "") {
this.context.transitionToIdleState();
return;
}
this.context.transitionToSuggestingState(prediction, this.prefix, this.suffix);
}
getStatusBarText(): string {
return `Predicting for ${this.context.context}`;
}
}
export default PredictingState;

View File

@ -0,0 +1,84 @@
import Context from "../context-detection";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
import State from "./state";
class QueuedState extends State {
private timer: ReturnType<typeof setTimeout> | null = null;
private readonly prefix: string;
private readonly suffix: string;
private constructor(
context: EventListener,
prefix: string,
suffix: string
) {
super(context);
this.prefix = prefix;
this.suffix = suffix;
}
static createAndStartTimer(
context: EventListener,
prefix: string,
suffix: string
): QueuedState {
const state = new QueuedState(context, prefix, suffix);
state.startTimer();
context.setContext(Context.getContext(prefix, suffix));
return state;
}
handleCancelKeyPressed(): boolean {
this.cancelTimer();
this.context.transitionToIdleState();
return true;
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
if (
documentChanges.isDocInFocus() &&
documentChanges.isTextAdded() &&
this.context.containsTriggerCharacters(documentChanges)
) {
this.cancelTimer();
this.context.transitionToQueuedState(documentChanges.getPrefix(), documentChanges.getSuffix());
return
}
if (
(documentChanges.hasCursorMoved() ||
documentChanges.hasUserTyped() ||
documentChanges.hasUserDeleted() ||
documentChanges.isTextAdded() ||
!documentChanges.isDocInFocus())
) {
this.cancelTimer();
this.context.transitionToIdleState();
}
}
startTimer(): void {
this.cancelTimer();
this.timer = setTimeout(() => {
this.context.transitionToPredictingState(this.prefix, this.suffix);
}, this.context.settings.delay);
}
private cancelTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
getStatusBarText(): string {
return `Queued (${this.context.settings.delay} ms)`;
}
}
export default QueuedState;

View File

@ -0,0 +1,67 @@
import { Notice, TFile } from "obsidian";
import EventListener from "../../../event-listener";
import { DocumentChanges } from "../../../render-plugin/document-changes-listener";
// import { Settings } from "../settings/versions";
import { InfioSettings } from "../../../types/settings";
import { checkForErrors } from "../../../utils/auto-complete";
import { EventHandler } from "./types";
abstract class State implements EventHandler {
protected readonly context: EventListener;
constructor(context: EventListener) {
this.context = context;
}
handleSettingChanged(settings: InfioSettings): void {
const settingErrors = checkForErrors(settings);
if (!settings.autocompleteEnabled) {
new Notice("Copilot is now disabled.");
this.context.transitionToDisabledManualState()
} else if (settingErrors.size > 0) {
new Notice(
`Copilot: There are ${settingErrors.size} errors in your settings. The plugin will be disabled until they are fixed.`
);
this.context.transitionToDisabledInvalidSettingsState();
} else if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToDisabledFileSpecificState();
}
}
async handleDocumentChange(
documentChanges: DocumentChanges
): Promise<void> {
}
handleAcceptKeyPressed(): boolean {
return false;
}
handlePartialAcceptKeyPressed(): boolean {
return false;
}
handleCancelKeyPressed(): boolean {
return false;
}
handlePredictCommand(prefix: string, suffix: string): void {
}
handleAcceptCommand(): void {
}
abstract getStatusBarText(): string;
handleFileChange(file: TFile): void {
if (this.context.isCurrentFilePathIgnored() || this.context.currentFileContainsIgnoredTag()) {
this.context.transitionToDisabledFileSpecificState();
} else if (this.context.isDisabled()) {
this.context.transitionToIdleState();
}
}
}
export default State;

Some files were not shown because too many files have changed in this diff Show More