init
This commit is contained in:
commit
0c7ee142cb
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
55
.eslintignore
Normal 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
66
.eslintrc.js
Normal 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
23
.github/workflows/ci.yml
vendored
Normal 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
34
.github/workflows/release.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
|
||||||
53
.prettierignore
Normal file
53
.prettierignore
Normal 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
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
56
README.md
Normal 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'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 & 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
6
__mocks__/obsidian.ts
Normal 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()
|
||||||
4
demo_vault/Block quote (a tale of two cities).md
Normal file
4
demo_vault/Block quote (a tale of two cities).md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# A Tale of Two Cities
|
||||||
|
|
||||||
|
The most famous quote from this book is:
|
||||||
|
>
|
||||||
21
demo_vault/Code generation (Kadane algorithm python).md
Normal file
21
demo_vault/Code generation (Kadane algorithm python).md
Normal 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
|
||||||
|
|
||||||
|
```
|
||||||
21
demo_vault/Code generation (Kadane algorithm rust).md
Normal file
21
demo_vault/Code generation (Kadane algorithm rust).md
Normal 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
|
||||||
|
|
||||||
|
```
|
||||||
22
demo_vault/Code generation (Kadane algorithm typescript).md
Normal file
22
demo_vault/Code generation (Kadane algorithm typescript).md
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -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
|
||||||
7
demo_vault/List completion test (dead relu reasons).md
Normal file
7
demo_vault/List completion test (dead relu reasons).md
Normal 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.
|
||||||
24
demo_vault/Math block test (softmax math function).md
Normal file
24
demo_vault/Math block test (softmax math function).md
Normal 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.
|
||||||
@ -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
|
||||||
|
|
||||||
|
```
|
||||||
8
demo_vault/Python code completion test (fizz buzz).md
Normal file
8
demo_vault/Python code completion test (fizz buzz).md
Normal 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
22
demo_vault/README.md
Normal 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.
|
||||||
@ -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}
|
||||||
|
$$
|
||||||
12
demo_vault/Text completion test (git sha hash checksums).md
Normal file
12
demo_vault/Text completion test (git sha hash checksums).md
Normal 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
|
||||||
3
demo_vault/Title test (Chinese bitcoin).md
Normal file
3
demo_vault/Title test (Chinese bitcoin).md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#
|
||||||
|
|
||||||
|
比特币是一种新型的数字货币。 它与其他类型的数字货币不同,因为它的运作没有中央机构,也没有银行等中介机构。 相反,它是一个开放且去中心化的网络,由用户控制。 该网络遍布全球,任何人都可以参与。 由于底层技术的原因,比特币的工作原理与大多数人习惯的不同。 要完全理解它,您需要学习一段时间。 但有多少人了解互联网背后的技术是如何运作的? 最重要的是你可以使用它:你不必成为专家就可以安全地存储或发送比特币。 对于那些想了解更多信息的人,我们在下面编写了技术说明。 虽然不足以称自己为比特币专家,但也许足以在酒吧里留下深刻的印象. 你不必成为专家就可以安全地存储或发送比特币。
|
||||||
52
demo_vault/Title test (adapter design pattern).md
Normal file
52
demo_vault/Title test (adapter design pattern).md
Normal 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.
|
||||||
6
demo_vault/Title test (dutch bitcoin).md
Normal file
6
demo_vault/Title test (dutch bitcoin).md
Normal 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.
|
||||||
6
demo_vault/Title test (english bitcoin).md
Normal file
6
demo_vault/Title test (english bitcoin).md
Normal 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.
|
||||||
@ -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
|
||||||
|
- [ ]
|
||||||
@ -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
57
esbuild.config.mjs
Normal 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
7
import-meta-url-shim.js
Normal 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
7
jest.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+.tsx?$': ['ts-jest', {}],
|
||||||
|
},
|
||||||
|
}
|
||||||
11
manifest.json
Normal file
11
manifest.json
Normal 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
88
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
scripts/bundle-pglite-resources.mjs
Normal file
39
scripts/bundle-pglite-resources.mjs
Normal 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
53
src/ApplyView.tsx
Normal 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
117
src/ChatView.tsx
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/components/apply-view/ApplyViewRoot.tsx
Normal file
142
src/components/apply-view/ApplyViewRoot.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/components/chat-view/AssistantMessageActions.tsx
Normal file
90
src/components/chat-view/AssistantMessageActions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
737
src/components/chat-view/Chat.tsx
Normal file
737
src/components/chat-view/Chat.tsx
Normal 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
|
||||||
202
src/components/chat-view/ChatListDropdown.tsx
Normal file
202
src/components/chat-view/ChatListDropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/components/chat-view/CreateTemplateDialog.tsx
Normal file
127
src/components/chat-view/CreateTemplateDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/chat-view/LLMResponseInfoPopover.tsx
Normal file
84
src/components/chat-view/LLMResponseInfoPopover.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
src/components/chat-view/MarkdownCodeComponent.tsx
Normal file
122
src/components/chat-view/MarkdownCodeComponent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/components/chat-view/MarkdownReferenceBlock.tsx
Normal file
75
src/components/chat-view/MarkdownReferenceBlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/components/chat-view/QueryProgress.tsx
Normal file
85
src/components/chat-view/QueryProgress.tsx
Normal 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>
|
||||||
|
}
|
||||||
64
src/components/chat-view/ReactMarkdown.tsx
Normal file
64
src/components/chat-view/ReactMarkdown.tsx
Normal 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)
|
||||||
38
src/components/chat-view/ShortcutInfo.tsx
Normal file
38
src/components/chat-view/ShortcutInfo.tsx
Normal 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;
|
||||||
71
src/components/chat-view/SimilaritySearchResults.tsx
Normal file
71
src/components/chat-view/SimilaritySearchResults.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/chat-view/SyntaxHighlighterWrapper.tsx
Normal file
51
src/components/chat-view/SyntaxHighlighterWrapper.tsx
Normal 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)
|
||||||
374
src/components/chat-view/chat-input/ChatUserInput.tsx
Normal file
374
src/components/chat-view/chat-input/ChatUserInput.tsx
Normal 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
|
||||||
30
src/components/chat-view/chat-input/ImageUploadButton.tsx
Normal file
30
src/components/chat-view/chat-input/ImageUploadButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/components/chat-view/chat-input/LexicalContentEditable.tsx
Normal file
153
src/components/chat-view/chat-input/LexicalContentEditable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
319
src/components/chat-view/chat-input/MentionableBadge.tsx
Normal file
319
src/components/chat-view/chat-input/MentionableBadge.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/components/chat-view/chat-input/ModelSelect.tsx
Normal file
51
src/components/chat-view/chat-input/ModelSelect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/chat-view/chat-input/SubmitButton.tsx
Normal file
12
src/components/chat-view/chat-input/SubmitButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/chat-view/chat-input/VaultChatButton.tsx
Normal file
42
src/components/chat-view/chat-input/VaultChatButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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!')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 ''
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
271
src/components/inline-edit/InlineEdit.tsx
Normal file
271
src/components/inline-edit/InlineEdit.tsx
Normal 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
157
src/constants.ts
Normal 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'
|
||||||
23
src/contexts/AppContext.tsx
Normal file
23
src/contexts/AppContext.tsx
Normal 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
|
||||||
|
}
|
||||||
46
src/contexts/DarkModeContext.tsx
Normal file
46
src/contexts/DarkModeContext.tsx
Normal 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
|
||||||
|
}
|
||||||
58
src/contexts/DatabaseContext.tsx
Normal file
58
src/contexts/DatabaseContext.tsx
Normal 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
|
||||||
|
}
|
||||||
27
src/contexts/DialogContext.tsx
Normal file
27
src/contexts/DialogContext.tsx
Normal 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
135
src/contexts/LLMContext.tsx
Normal 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
|
||||||
|
}
|
||||||
39
src/contexts/RAGContext.tsx
Normal file
39
src/contexts/RAGContext.tsx
Normal 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
|
||||||
|
}
|
||||||
58
src/contexts/SettingsContext.tsx
Normal file
58
src/contexts/SettingsContext.tsx
Normal 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
|
||||||
|
}
|
||||||
95
src/core/autocomplete/context-detection.ts
Normal file
95
src/core/autocomplete/context-detection.ts
Normal 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;
|
||||||
273
src/core/autocomplete/index.ts
Normal file
273
src/core/autocomplete/index.ts
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
96
src/core/autocomplete/post-processors/remove-overlap.ts
Normal file
96
src/core/autocomplete/post-processors/remove-overlap.ts
Normal 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;
|
||||||
24
src/core/autocomplete/post-processors/remove-whitespace.ts
Normal file
24
src/core/autocomplete/post-processors/remove-whitespace.ts
Normal 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;
|
||||||
34
src/core/autocomplete/pre-processors/data-view-remover.ts
Normal file
34
src/core/autocomplete/pre-processors/data-view-remover.ts
Normal 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;
|
||||||
24
src/core/autocomplete/pre-processors/length-limiter.ts
Normal file
24
src/core/autocomplete/pre-processors/length-limiter.ts
Normal 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;
|
||||||
35
src/core/autocomplete/states/disabled-file-specific-state.ts
Normal file
35
src/core/autocomplete/states/disabled-file-specific-state.ts
Normal 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;
|
||||||
@ -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;
|
||||||
21
src/core/autocomplete/states/disabled-manual-state.ts
Normal file
21
src/core/autocomplete/states/disabled-manual-state.ts
Normal 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;
|
||||||
46
src/core/autocomplete/states/idle-state.ts
Normal file
46
src/core/autocomplete/states/idle-state.ts
Normal 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;
|
||||||
38
src/core/autocomplete/states/init-state.ts
Normal file
38
src/core/autocomplete/states/init-state.ts
Normal 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;
|
||||||
94
src/core/autocomplete/states/predicting-state.ts
Normal file
94
src/core/autocomplete/states/predicting-state.ts
Normal 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;
|
||||||
84
src/core/autocomplete/states/queued-state.ts
Normal file
84
src/core/autocomplete/states/queued-state.ts
Normal 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;
|
||||||
67
src/core/autocomplete/states/state.ts
Normal file
67
src/core/autocomplete/states/state.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user