Compare commits

..

44 commits
1.0.0 ... main

Author SHA1 Message Date
HyeonseoNam
edb47272cc chore: Edit minor sentence 2025-07-11 02:53:53 +09:00
HyeonseoNam
aa9819135a docs: Update REAEME - contributors, minor sentence 2025-07-11 02:47:16 +09:00
HyeonseoNam
ae3c606173 docs: Update REAEME - default gpt model, local llm, jina api 2025-07-11 02:42:52 +09:00
HyeonseoNam
8910772a7b feat: Edit openai -> openai-compatible engine & Improve error handlings
- Edit Openai engine to Openai-compatible engine
- Enhance error messages for specific API response statuses (401, 403, 429, 500).
- Update notices for missing API keys and classification limits in AutoClassifierPlugin.
- Refactor settings to provide clearer descriptions and examples for API configurations.
- Add token usage information in success messages for Jina AI API calls.
2025-07-11 02:19:59 +09:00
HyeonseoNam
24645b2812 chore: Update package infos 2025-07-11 02:06:41 +09:00
HyeonseoNam
1808c5f98d Merge branch 'main' into dev 2025-07-11 02:02:00 +09:00
Thiago Moraes
1f89e4e38b
feat: Integrate Jina AI classification API (#40)
* feat: Add configurable multiple tag suggestions with max limit setting

* refactor: Move createLoadingNotice method before its usage in runClassifyTag

* fix: Add missing catch block and error handling in classifyTag method

* fix: Add nested try-catch for JSON parsing and improve error handling

style: Format code with consistent indentation and line breaks

* refactor: Remove max_suggestions from template and apply globally in classification logic

* refactor: Move max suggestions setting to main Advanced Settings section

* improve prompt adherence for JSON (#33)

* fix: imporove default prompt template to ensure valid JSON output.

* bump version to 1.2.1

* Fix JSON parse error when other GPT model used (#38)

* Integrate Jina AI for text classification - rebased

- Update package-lock.json
- Add JinaAI API client in src/jina-api.ts.
- Update settings (src/settings.ts) to include Jina AI engine selection, API key, base URL, and model.
- Modify main.ts to conditionally call Jina AI or ChatGPT based on selected engine.
- Adjust response parsing for Jina AI output.
- Add Jina API test button in settings.
- Hide ChatGPT-specific advanced settings when Jina AI is selected and
  vice-versa.
- Update manifest.json.
- Update .gitignore.

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: Hyeonseo Nam <auspice7g@gmail.com>
Co-authored-by: SophomoreSo <57844175+SophomoreSo@users.noreply.github.com>
2025-07-09 23:33:37 +09:00
SophomoreSo
bab745f60a
Fix JSON parse error when other GPT model used (#38) 2025-02-19 12:59:10 +09:00
Robin Tuszik
baeaf47cb7
improve prompt adherence for JSON (#33)
* fix: imporove default prompt template to ensure valid JSON output.

* bump version to 1.2.1
2025-02-19 12:58:59 +09:00
Hyeonseo Nam
e02924f9b4
Merge pull request #29 from rtuszik/tag-limit
allow for multiple tags, tag limit
2024-12-12 22:22:33 +09:00
Hyeonseo Nam
9415fa0f91
Merge pull request #28 from rtuszik/develop
bump version, README changes
2024-12-12 22:06:30 +09:00
Robin Tuszik
f6b0afeeba refactor: Move max suggestions setting to main Advanced Settings section 2024-12-11 21:35:57 +01:00
Robin Tuszik
c1d7a78e5c refactor: Remove max_suggestions from template and apply globally in classification logic 2024-12-11 21:35:57 +01:00
Robin Tuszik
aedda20210 fix: Add nested try-catch for JSON parsing and improve error handling
style: Format code with consistent indentation and line breaks
2024-12-11 21:35:46 +01:00
Robin Tuszik
5583d04ac0 fix: Add missing catch block and error handling in classifyTag method 2024-12-11 21:09:47 +01:00
Robin Tuszik
053cec1d27 refactor: Move createLoadingNotice method before its usage in runClassifyTag 2024-12-11 21:09:39 +01:00
Robin Tuszik
d49c5199c5 feat: Add configurable multiple tag suggestions with max limit setting 2024-12-11 21:03:06 +01:00
Robin Tuszik
f991345fe3 docs: fix wording and inconsistencies 2024-12-11 20:57:18 +01:00
Robin Tuszik
7d4cb34ee2 fix: bump version for obsidian plugin release 2024-12-11 20:48:37 +01:00
Robin Tuszik
f6f268d209 docs: update readme for new settings parameters 2024-12-11 20:48:17 +01:00
Hyeonseo Nam
97706f765f
v1.2.0
Merge pull request #27 from rtuszik/openai
custom base url and model support (adds support for ollama, openrouter, etc.)
2024-12-11 00:06:51 +09:00
Robin Tuszik
540cf1f501 fix: add missing openai dependency 2024-12-08 05:07:38 +01:00
Robin Tuszik
4204be6185 fix: Update test API call to use baseURL setting 2024-12-08 04:10:14 +01:00
Robin Tuszik
475009ea40 feat: Move custom model selection to API settings and apply globally 2024-12-08 04:10:14 +01:00
Robin Tuszik
45f66586e9 feat(api): migrate to official OpenAI Node.js library
feat: Add configurable base URL for OpenAI API calls
2024-12-08 04:09:57 +01:00
Hyeonseo Nam
692419e361
Merge pull request #25 from tk42/main
Add custom model and max tokens options in settings
2024-04-27 23:23:50 +09:00
tk42
0681b42407 Refactor API options in ChatGPT class and add custom model and max tokens settings 2024-04-20 19:33:54 +09:00
Hyeonseo Nam
23a83fd6a6 v1.1.2 2024-03-05 22:25:54 +09:00
Hyeonseo Nam
7d00fe8622
Merge pull request #21 from gravityfargo/main 2024-03-03 17:55:32 +09:00
Nathan Price
a5478ec4cc
fix regex 2024-03-01 15:58:36 -05:00
Hyeonseo Nam
06c3c051ad v1.1.1 2023-05-06 01:38:00 +09:00
Hyeonseo Nam
d8aede8a3d Fix toggle save and some error handlings 2023-05-06 01:37:28 +09:00
Hyeonseo Nam
eed824884a v1.1.0 2023-05-04 03:01:34 +09:00
Hyeonseo Nam
1796ae9fa5 Edit README.md 2023-05-04 02:52:06 +09:00
Hyeonseo Nam
a9321e60fb Fix json regex more robust to catch the response 2023-05-04 02:33:07 +09:00
Hyeonseo Nam
c09fca3d9e Edit: change frontmatter, title to OutType from OutLocation 2023-05-04 02:30:52 +09:00
Hyeonseo Nam
d450bedaba Edit option order 2023-05-04 00:11:07 +09:00
Hyeonseo Nam
03bbf4150c Edit overwrite setting 2023-05-04 00:06:53 +09:00
Hyeonseo Nam
98fda81999 Add insertAtContentTop 2023-05-03 23:53:32 +09:00
Hyeonseo Nam
dc31456f5e Add wikilink 2023-05-03 22:50:42 +09:00
Hyeonseo Nam
8080215b40 Add toggle for using reference 2023-05-03 21:46:35 +09:00
Hyeonseo Nam
c30359fff3 Edit api: replace fetch to requestUrl 2023-03-24 20:43:28 +09:00
Hyeonseo Nam
310a038fb3 Edit settings: replace innerHTML to creatEl 2023-03-24 20:31:47 +09:00
Hyeonseo Nam
5e4dc7181a Edit settings: replace to tab.setQuery 2023-03-24 20:25:24 +09:00
11 changed files with 1598 additions and 406 deletions

5
.gitignore vendored
View file

@ -15,8 +15,11 @@ main.js
# Exclude sourcemaps
*.map
# obsidian
# obsidian data
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Exclude test
*.txt

153
README.md
View file

@ -1,63 +1,158 @@
# Auto Classifier
![GitHub release (latest by date)](https://img.shields.io/github/v/release/HyeonseoNam/auto-classifier?style=for-the-badge) ![GitHub all releases](https://img.shields.io/github/downloads/HyeonseoNam/auto-classifier/total?style=for-the-badge)
`Auto Classifier` is an [Obsidian](https://obsidian.md/) plugin that helps you automatically classify tag from your notes using the ChatGPT API. The plugin can analyze your note (It can be title, frontmatter, content or selected area) and suggest relevant tag based on the input with tags in your valut. This can be used for various and specific purposes, for example, DDC classification for books, keyword recommendation, research paper categorization and so on. Save you time and improving your note organization.
`Auto Classifier` is an [Obsidian](https://obsidian.md/) plugin that helps you automatically classify tags in your notes using either OpenAI-compatible APIs (ChatGPT, Ollama, LocalAI, etc.) or Jina AI Classifier. The plugin can analyze your note (including its title, frontmatter, content, or selected area) and suggest relevant tags based on the input with tags in your vault. This can be used for various specific purposes, for example, DDC classification for books, keyword recommendation, research paper categorization, and so on. Save time and improve your note organization.
## How to use
- Enter your ChatGPT API key in setting tab. You can create it at [OpenAI](https://platform.openai.com/account/api-keys). Check api setting by the `Test API call` button.
- Configure your classification engine and API settings in the settings tab:
- This plugin consists of **4 Input Commands** that you can run. By simply running these commands, it will automatically classify your note.
- `Classify tag from Note title`
- `Classify tag from Note FrontMatter`
- `Classify tag from Note Content`
- `Classify tag from Selected Area`
- **Choose your Classification Engine:**
- `OpenAI-compatible API`: Uses OpenAI API, Local AI (Ollama, LocalAI), or other compatible APIs
- `Jina AI`: Uses Jina AI Classifier (cost-effective, multilingual)
- **For OpenAI-compatible API:**
- Enter your API key (leave empty for local AI)
- Set the base URL:
- OpenAI: `https://api.openai.com/v1` (default)
- Local OpenAI-compatible API (Ollama, LocalAI):
- Ollama: `http://localhost:11434/v1`
- LocalAI: `http://localhost:8080/v1`
- Choose your model:
- OpenAI: `gpt-4.1-mini` (recommended), `gpt-4.1`, `gpt-4o`, ...
- Local OpenAI-compatible API: `llama3`, `mistral`, `phi3`, `qwen2`, ... (Ollama/LocalAI)
- Test your configuration using the Test API call button
- **For Jina AI:**
- Enter your Jina AI API key (free tier available with 10M tokens)
- Choose your preferred model (default: jina-embeddings-v3)
- Test your configuration using the Test Jina API button
- **Getting a Jina AI API Key**: Visit the [Jina AI website](https://jina.ai/) to obtain a free temporary API key (you can get the key without creating an account).
- This plugin consists of **4 Input Commands** that you can run. By simply running these commands, it will automatically classify your note:
- `Classify tag from Note title`
- `Classify tag from Note FrontMatter`
- `Classify tag from Note Content`
- `Classify tag from Selected Area`
- Toggle and choose from different **Tag Reference** types. The LLM will select the appropriate tag from these references:
- `All tags` (default)
- `Filtered Tags` with regular expression
- `Manual Tags` that are defined manually
- Specify the **Output Type** from the response of the LLM:
- `#Tag`: at your `Current Cursor` or `Top of Content`
- `[[WikiLink]]`: at your `Current Cursor` or `Top of Content`
- `FrontMatter`: with `key`
- `Title Alternative`: at the end of note's title
- (Optional) Add a `Prefix` or `Suffix` for the output format.
- (Optional) You can use your custom request for your selected API:
- `Custom Prompt Template`
- The LLM will respond based on this prompt. The input coming from your Command will be replaced by `{{input}}`, and the reference tags you set will be placed in `{{reference}}`.
- `Custom Chat Role`
- You can guide the AI's behavior by setting this system role
## Classification Engines
### OpenAI-compatible API
- **Flexibility**: Supports custom prompts and chat roles
- **Multiple Providers**: Works with OpenAI, Ollama, LocalAI, and other compatible APIs
- **Local AI Support**: Run models locally without internet connection
- **Recommended OpenAI models**: `gpt-4.1-mini`, `gpt-4.1`, `gpt-4o`
- **Advanced Features**: Custom prompt templates, system roles, and token control
### Jina AI Classifier
- **Cost-Effective**: Generous free tier with 10 million tokens
- **Multilingual**: Supports multiple languages including non-English content
- **High Performance**: Zero-shot classification with up to 8192 tokens input and 256 distinct classes
- **Optimized**: Works best with semantic labels (e.g., "happy", "sad", "angry")
- **Easy Setup**: Often no account creation required, can generate fresh API keys as needed
## Local AI Setup (Ollama, LocalAI)
**Experimental Support:**
⚠️ You may use local OpenAI-compatible APIs such as Ollama or LocalAI. However, support for these engines is experimental and full compatibility or stability is **not guaranteed**. Some features may not work as expected. Please test thoroughly before relying on these engines for important workflows.
If you encounter issues or want to help improve compatibility, **contributions and pull requests are very welcome!**
**Example setup:**
1. Install [Ollama](https://ollama.ai/) or [LocalAI](https://localai.io/)
2. Prepare your model: For Ollama, run `ollama pull llama3`. For LocalAI, configure your models as needed.
3. Set Base URL: Ollama - `http://localhost:11434/v1`, LocalAI - `http://localhost:8080/v1`
4. Set Model: e.g., `llama3`, `mistral`, etc.
5. API Key: Leave empty
- Choose from different **Tag Reference** types. ChatGPT will select the appropriate tag from these references.
- `All tags` (default)
- `Filtered Tags` with regular expression
- `Manual Tags` that defined manually
- Specify the **Output Location** for the generated tag and decide whether it **overwrites**:
- At `FrontMatter` (with specified key)
- At `Title`
- At `Current Cursor`
- (Optional) Your can use your custom request for the ChatGPT API.
- `Custom Prompt Template`
- ChatGPT will respond based on this prompt. The input coming from your Command will be replaced by `{{input}}`, and the reference tags you set will be placed in `{{reference}}`.
- `Custom Chat Role`
- You can guide ChatGPT's behabior by setting this system role
## Example
### Use Case #1: **Selected area** &rightarrow; **Current cursor**
![](img/selected_to_cursor.gif)
### Use Case #2: **Content** &rightarrow; **FrontMatter**
![](img/content_to_frontmatter.gif)
### Use Case #3: **FrontMatter** &rightarrow; **Title**
![](img/frontmatter_to_totle.gif)
### Use Case #4: **Title** &rightarrow; **FrontMatter**
![](img/title_to_frontmatter.gif)
### DDC number classification
If you want to use this plugin for DDC number classification, edit the `Custom Prompt Template` like this:
```
Please use Dewey Decimal Classification (DDC) to classify this content:
"""
{{input}}
"""
Answer format is JSON {reliability:0~1, output:"[ddc_number]:category"}.
Even if you are not sure, qualify the reliability and select one.
Convert the blank spaces to "_" in the output.
```
### LCSH classification
LCSH classification can be similar:
```
Please use Library of Congress Subject Headings (LCSH) to classify this content:
"""
{{input}}
"""
Answer format is JSON {reliability:0~1, output:"[First LCSH term]--[Second LCSH term]--[Third LCSH term]"}.
Even if you are not sure, qualify the reliability and select one.
Convert the blank spaces to "_" in the output.
```
## Installation
- Search `Auto Classifier` in the Community plugin tab of the Obsidian settings.
- Also, you can manually download the latest release from this repository [GitHub releases](https://github.com/hyeonseonam/auto-tagger/releases) and extrat the ZIP file to your Obsidian plugins folder.
- Search for `Auto Classifier` in the Community plugin tab of the Obsidian settings.
- Alternatively, you can manually download the latest release from this repository's [GitHub releases](https://github.com/hyeonseonam/auto-tagger/releases) and extract the ZIP file to your Obsidian plugins folder.
## Support
If you encounter any issues while using this plugin or have any suggestions for improvement, please feel free to submit an issue on the GitHub repository. Pull requests are also welcome.
If you encounter any issues while using this plugin or have suggestions for improvement, please feel free to submit an issue on the GitHub repository. Pull requests are also welcome.
## Author
## 🙌 Contributors
- @gravityfargo
- @rtuszik
- @tk42
- @SophomoreSo
- @ThiagoVsky
Hyeonseo Nam
## License
MIT License
MIT License

View file

@ -1,9 +1,9 @@
{
"id": "auto-classifier",
"name": "Auto Classifier",
"version": "1.0.0",
"version": "1.3.0",
"minAppVersion": "1.1.1",
"description": "This plugin automatically classify tag from your notes using ChatGPT API. It analyze your note (It can be title, frontmatter, content or selected area) and automatically insert tag where you set.",
"description": "This plugin automatically classify tag from your notes using ChatGPT API or Jina Classifier. It analyze your note (It can be title, frontmatter, content or selected area) and automatically insert tag where you set.",
"author": "Hyeonseo Nam",
"authorUrl": "https://github.com/HyeonseoNam/auto-classifier",
"isDesktopOnly": false

430
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "auto-classifier",
"version": "1.0.0",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auto-classifier",
"version": "1.0.0",
"version": "1.1.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
@ -15,6 +15,7 @@
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"openai": "4.76.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
@ -212,6 +213,16 @@
"integrity": "sha512-wvzClDGQXOCVNU4APPopC2KtMYukaF1MN/W3xAmslx22Z4/IF1/izDMekuyoUlwfnDHYCIZGaj7jMwnJKBTxKw==",
"dev": true
},
"node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
@ -405,6 +416,18 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dev": true,
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
@ -428,6 +451,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"dev": true,
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -487,6 +522,12 @@
"node": ">=8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -529,6 +570,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -576,6 +630,18 @@
"dev": true,
"peer": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -622,6 +688,15 @@
"dev": true,
"peer": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -647,6 +722,65 @@
"node": ">=6.0.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz",
@ -909,6 +1043,15 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -1030,6 +1173,41 @@
"dev": true,
"peer": true
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"dev": true
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"dev": true,
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -1037,12 +1215,58 @@
"dev": true,
"peer": true
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"dev": true
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -1113,6 +1337,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
@ -1130,6 +1366,54 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"dev": true,
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -1318,6 +1602,15 @@
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -1340,6 +1633,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -1375,6 +1689,46 @@
"dev": true,
"peer": true
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/obsidian": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.1.1.tgz",
@ -1399,6 +1753,41 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "4.76.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.76.1.tgz",
"integrity": "sha512-ci63/WFEMd6QjjEVeH0pV7hnFS6CCqhgJydSti4Aak/8uo2SpgzKjteUDaY+OkwziVj11mi6j+0mRUIiGKUzWw==",
"dev": true,
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.115",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.115.tgz",
"integrity": "sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/optionator": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@ -1736,6 +2125,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
},
"node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
@ -1802,6 +2197,12 @@
"node": ">=4.2.0"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -1819,6 +2220,31 @@
"dev": true,
"peer": true
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"dev": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -1,15 +1,15 @@
{
"name": "auto-classifier",
"version": "1.0.0",
"description": "This plugin automatically classify tag from your notes using ChatGPT API. It analyze your note (It can be title, frontmatter, content or selected area) and automatically insert tag where you set.",
"version": "1.3.0",
"description": "This plugin automatically classify tag from your notes using OpenAI-compatible APIs (ChatGPT, Ollama, LocalAI) or Jina AI Classifier. It analyze your note (It can be title, frontmatter, content or selected area) and automatically insert tag where you set.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"keywords": ["obsidian", "plugin", "classification", "tags", "ai", "chatgpt", "jina", "automation"],
"author": "Hyeonseo Nam",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
@ -19,6 +19,7 @@
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "4.7.4",
"openai": "4.76.1"
}
}

View file

@ -1,50 +1,44 @@
export class ChatGPT {
private static baseUrl = 'https://api.openai.com/v1/chat/completions';
import OpenAI from 'openai';
export class ChatGPT {
static async callAPI(
system_role: string,
user_prompt: string,
apiKey: string,
model = "gpt-3.5-turbo",
temperature = 0,
max_tokens = 150,
top_p = 0.95,
frequency_penalty = 0,
presence_penalty = 0.5): Promise<string> {
const headers = new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
model: string = 'gpt-3.5-turbo',
max_tokens: number = 150,
temperature: number = 0,
top_p: number = 0.95,
frequency_penalty: number = 0,
presence_penalty: number = 0.5,
baseURL?: string,
): Promise<string> {
const client = new OpenAI({
apiKey: apiKey,
dangerouslyAllowBrowser: true, // Required for client-side use
baseURL: baseURL || 'https://api.openai.com/v1'
});
const body = JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
{ "role": "system", "content": system_role },
{ "role": "user", "content": user_prompt },
],
max_tokens: max_tokens,
n: 1,
// stop: '\n',
stop: null,
temperature: temperature,
top_p: top_p,
frequency_penalty: frequency_penalty,
presence_penalty: presence_penalty
});
try {
const completion = await client.chat.completions.create({
model: model,
messages: [
{ role: "system", content: system_role },
{ role: "user", content: user_prompt }
],
max_tokens: max_tokens,
temperature: temperature,
top_p: top_p,
frequency_penalty: frequency_penalty,
presence_penalty: presence_penalty
});
const response = await fetch(`${this.baseUrl}`, {
method: 'POST',
headers: headers,
body: body,
});
if (!response.ok) {
throw new Error(`API call error: ${response.statusText}`);
return completion.choices[0].message.content || '';
} catch (error) {
if (error instanceof OpenAI.APIError) {
throw new Error(`OpenAI API Error: ${error.status} - ${error.message}`);
}
throw error;
}
const data = await response.json();
// return data.choices[0].text.trim();
return data.choices[0].message.content;
}
}
}

100
src/jina-api.ts Normal file
View file

@ -0,0 +1,100 @@
import { requestUrl } from 'obsidian';
export interface JinaAPIRequest {
model: string;
input: Array<{ text: string }>;
labels: string[];
}
export interface JinaAPIResponsePrediction {
label: string;
score: number;
}
export interface JinaAPIResponseData {
object: string;
index: number;
prediction: string;
score: number;
predictions: JinaAPIResponsePrediction[];
}
export interface JinaAPIResponse {
usage: {
total_tokens: number;
};
data: JinaAPIResponseData[];
}
export class JinaAI {
static async callAPI(
apiKey: string,
baseURL: string,
model: string,
inputText: string[], // Changed from string to string[] to allow multiple inputs if needed by the plugin logic later
labels: string[]
): Promise<JinaAPIResponse> {
const apiUrl = `${baseURL}/classify`; // Assuming baseURL does not contain /classify
const requestBody: JinaAPIRequest = {
model: model,
input: inputText.map(text => ({ text })),
labels: labels,
};
console.log('Jina AI Request:', { url: apiUrl, body: requestBody });
try {
const response = await requestUrl({
url: apiUrl,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
throw: false, // To handle errors manually
});
console.log('Jina AI Response Status:', response.status);
console.log('Jina AI Response Body:', response.text); // Log raw text for debugging
if (response.status !== 200) {
let errorDetails = response.text;
try {
const jsonError = JSON.parse(response.text);
errorDetails = jsonError.detail || JSON.stringify(jsonError);
} catch (e) {
// Keep errorDetails as text if not JSON
}
// More specific error messages
let userFriendlyMessage = `Jina AI API Error (${response.status})`;
if (response.status === 401) {
userFriendlyMessage = "Invalid Jina AI API key. Please check your API key in settings.";
} else if (response.status === 403) {
userFriendlyMessage = "Jina AI API access denied. Please check your API key permissions.";
} else if (response.status === 429) {
userFriendlyMessage = "Jina AI API rate limit exceeded. Please try again later.";
} else if (response.status === 500) {
userFriendlyMessage = "Jina AI server error. Please try again later.";
} else {
userFriendlyMessage = `Jina AI API Error: ${errorDetails}`;
}
console.error(`Jina AI API Error: ${response.status} - ${errorDetails}`);
throw new Error(userFriendlyMessage);
}
const responseData: JinaAPIResponse = response.json;
console.log('Jina AI Parsed Response Data:', responseData);
return responseData;
} catch (error) {
console.error('Error calling Jina AI API:', error);
if (error instanceof Error) {
throw error;
}
throw new Error('An unknown error occurred while calling Jina AI API.');
}
}
}

View file

@ -1,13 +1,21 @@
import { Plugin, Notice } from "obsidian";
import { AutoClassifierSettingTab, AutoClassifierSettings, DEFAULT_SETTINGS, OutLocation } from "src/settings";
import {
AutoClassifierSettingTab,
AutoClassifierSettings,
DEFAULT_SETTINGS,
OutLocation,
OutType,
ClassifierEngine, // Added ClassifierEngine
} from "src/settings";
import { ViewManager } from "src/view-manager";
import { ChatGPT } from 'src/api';
import { ChatGPT } from "src/api";
import { JinaAI, JinaAPIResponse } from "src/jina-api"; // Added JinaAI and JinaAPIResponse
enum InputType {
SelectedArea,
Title,
FrontMatter,
Content
Content,
}
export default class AutoClassifierPlugin extends Plugin {
@ -19,32 +27,32 @@ export default class AutoClassifierPlugin extends Plugin {
// Commands
this.addCommand({
id: 'classify-tag-selected',
name: 'Classify tag from Selected Area',
id: "classify-tag-selected",
name: "Classify tag from Selected Area",
callback: async () => {
await this.runClassifyTag(InputType.SelectedArea);
}
},
});
this.addCommand({
id: 'classify-tag-title',
name: 'Classify tag from Note Title',
id: "classify-tag-title",
name: "Classify tag from Note Title",
callback: async () => {
await this.runClassifyTag(InputType.Title);
}
},
});
this.addCommand({
id: 'classify-tag-frontmatter',
name: 'Classify tag from FrontMatter',
id: "classify-tag-frontmatter",
name: "Classify tag from FrontMatter",
callback: async () => {
await this.runClassifyTag(InputType.FrontMatter);
}
},
});
this.addCommand({
id: 'classify-tag-content',
name: 'Classify tag from Note Content',
id: "classify-tag-content",
name: "Classify tag from Note Content",
callback: async () => {
await this.runClassifyTag(InputType.Content);
}
},
});
this.addSettingTab(new AutoClassifierSettingTab(this.app, this));
@ -57,7 +65,26 @@ export default class AutoClassifierPlugin extends Plugin {
await this.saveData(this.settings);
}
async onunload() {
async onunload() {}
// create loading spin in the Notice message
createLoadingNotice(text: string, number = 10000): Notice {
const notice = new Notice("", number);
const loadingContainer = document.createElement("div");
loadingContainer.addClass("loading-container");
const loadingIcon = document.createElement("div");
loadingIcon.addClass("loading-icon");
const loadingText = document.createElement("span");
loadingText.textContent = text;
//@ts-ignore
notice.noticeEl.empty();
loadingContainer.appendChild(loadingIcon);
loadingContainer.appendChild(loadingText);
//@ts-ignore
notice.noticeEl.appendChild(loadingContainer);
return notice;
}
async runClassifyTag(inputType: InputType) {
@ -73,31 +100,46 @@ export default class AutoClassifierPlugin extends Plugin {
// Main Classification
async classifyTag(inputType: InputType) {
const commandOption = this.settings.commandOption;
const currentEngine = this.settings.classifierEngine;
// ------- [API Key check] -------
if (!this.settings.apiKey) {
new Notice(`${this.manifest.name}: You shuld input your API Key`);
return null
// Note: Local AI (Ollama, LocalAI) usually doesn't require API keys
if (currentEngine === ClassifierEngine.ChatGPT && !this.settings.apiKey) {
// Check if it's likely a local AI setup (localhost or local IP)
const baseUrl = this.settings.baseURL.toLowerCase();
if (!baseUrl.includes('localhost') && !baseUrl.includes('127.0.0.1') && !baseUrl.includes('192.168.')) {
new Notice(`${this.manifest.name}: API Key is missing. Required for most cloud APIs.`);
return null;
}
}
if (currentEngine === ClassifierEngine.JinaAI && !this.settings.jinaApiKey) {
new Notice(`${this.manifest.name}: Jina AI API Key is missing.`);
return null;
}
// ------- [Input] -------
const refs = this.settings.commandOption.refs;
// reference check
if (!refs || refs.length == 0) {
if (this.settings.commandOption.useRef && (!refs || refs.length == 0)) {
new Notice(`${this.manifest.name}: no reference tags`);
return null
return null;
}
// Set Input
let input: string | null = '';
// Jina AI has a limit of 256 classes for zero-shot classification
if (currentEngine === ClassifierEngine.JinaAI && refs.length > 256) {
new Notice(`${this.manifest.name}: Jina AI supports maximum 256 reference tags, but ${refs.length} were provided. Please reduce the number of tags.`);
return null;
}
// Set Input
let input: string | null = "";
if (inputType == InputType.SelectedArea) {
input = await this.viewManager.getSelection();
}
else if (inputType == InputType.Title) {
} else if (inputType == InputType.Title) {
input = await this.viewManager.getTitle();
}
else if (inputType == InputType.FrontMatter) {
} else if (inputType == InputType.FrontMatter) {
input = await this.viewManager.getFrontMatter();
}
else if (inputType == InputType.Content) {
} else if (inputType == InputType.Content) {
input = await this.viewManager.getContent();
}
@ -107,89 +149,143 @@ export default class AutoClassifierPlugin extends Plugin {
return null;
}
// Replace {{input}}, {{reference}}
let user_prompt = this.settings.commandOption.prmpt_template;
user_prompt = user_prompt.replace('{{input}}', input);
user_prompt = user_prompt.replace('{{reference}}', refs.join(','));
// Replace {{input}}, {{reference}} for ChatGPT
let user_prompt = "";
let system_role = "";
const system_role = this.settings.commandOption.prmpt_template;
if (currentEngine === ClassifierEngine.ChatGPT) {
user_prompt = this.settings.commandOption.prmpt_template;
user_prompt = user_prompt.replace("{{input}}", input);
user_prompt = user_prompt.replace("{{reference}}", refs.join(","));
system_role = this.settings.commandOption.chat_role; // Corrected from prmpt_template
}
// ------- [API Processing] -------
// Call API
const responseRaw = await ChatGPT.callAPI(system_role, user_prompt, this.settings.apiKey);
try {
let outputs: string[] = [];
let jinaResponse: JinaAPIResponse | null = null;
// String type to JSON type
const regexToJson = /\{([^}]+)\}/g;
const match = responseRaw.match(regexToJson);
let resOutput;
let resReliabity;
if (match) {
const resJson = JSON.parse(match[0]);
// Property check
if (!resJson.hasOwnProperty('output') || !resJson.hasOwnProperty('reliability')) {
new Notice(`${this.manifest.name}: output format error (No 'output' and 'reliability' key)`);
return null;
}
resOutput = resJson.output;
resReliabity = resJson.reliability;
} else
if (!match) {
// Property check
const resOutputRegex = /utput:\s*([^\n\r]+)/;
const resReliabityRegex = /eliability:\s*([^\n\r]+)/;
if (currentEngine === ClassifierEngine.ChatGPT) {
const responseRaw = await ChatGPT.callAPI(
system_role,
user_prompt,
this.settings.apiKey,
this.settings.commandOption.model,
this.settings.commandOption.max_tokens,
undefined,
undefined,
undefined,
undefined,
this.settings.baseURL,
);
// Parse ChatGPT response
try {
resOutput = String(responseRaw.match(resOutputRegex)?.[1]);
resReliabity = parseFloat(String(responseRaw.match(resReliabityRegex)?.[1]));
} catch (err) {
new Notice(`${this.manifest.name}: output format error`);
const response = JSON.parse(responseRaw.replace(/^```json\n/, "").replace(/\n```$/, ""));
const resReliability = response.reliability;
const resOutputs = response.outputs;
if (!Array.isArray(resOutputs)) {
new Notice(`${this.manifest.name}: ChatGPT output format error (expected array)`);
return null;
}
if (resReliability <= 0.2 && commandOption.useRef) { // Reliability check only if using references
new Notice(
`${this.manifest.name}: ChatGPT response has low reliability (${resReliability})`,
);
return null;
}
outputs = resOutputs;
} catch (error) {
new Notice(`${this.manifest.name}: ChatGPT JSON parsing error - ${error}`);
console.error("ChatGPT JSON parsing error:", error, "Raw response:", responseRaw);
return null;
}
} else if (currentEngine === ClassifierEngine.JinaAI) {
jinaResponse = await JinaAI.callAPI(
this.settings.jinaApiKey,
this.settings.jinaBaseURL,
this.settings.commandOption.model || 'jina-embeddings-v3', // Ensure model is passed
[input], // Jina expects an array of texts
refs
);
// Extract labels from Jina AI response
// Assuming we take the top prediction for the first input text.
// If multiple inputs were sent, this logic would need to iterate jinaResponse.data
if (jinaResponse.data && jinaResponse.data.length > 0) {
// Sort predictions by score and take the top ones
const sortedPredictions = jinaResponse.data[0].predictions.sort((a, b) => b.score - a.score);
outputs = sortedPredictions.map(p => p.label);
} else {
new Notice(`${this.manifest.name}: Jina AI returned no data.`);
return null;
}
}
// Avoid row reliability
if (resReliabity <= 0.2) {
new Notice(`${this.manifest.name}: response has row reliability (${resReliabity})`);
// Limit number of suggestions
const limitedOutputs = outputs.slice(0, this.settings.commandOption.max_suggestions);
if (limitedOutputs.length === 0) {
new Notice(`${this.manifest.name}: No tags were classified.`);
return null;
}
// ------- [Add Tags] -------
for (const resOutput of limitedOutputs) {
// Output Type 1. [Tag Case] + Output Type 2. [Wikilink Case]
if (
commandOption.outType == OutType.Tag ||
commandOption.outType == OutType.Wikilink
) {
if (commandOption.outLocation == OutLocation.Cursor) {
this.viewManager.insertAtCursor(
resOutput,
commandOption.overwrite,
commandOption.outType,
commandOption.outPrefix,
commandOption.outSuffix,
);
} else if (commandOption.outLocation == OutLocation.ContentTop) {
this.viewManager.insertAtContentTop(
resOutput,
commandOption.outType,
commandOption.outPrefix,
commandOption.outSuffix,
);
}
}
// Output Type 3. [Frontmatter Case]
else if (commandOption.outType == OutType.FrontMatter) {
this.viewManager.insertAtFrontMatter(
commandOption.key,
resOutput,
commandOption.overwrite,
commandOption.outPrefix,
commandOption.outSuffix,
);
}
// Output Type 4. [Title]
else if (commandOption.outType == OutType.Title) {
this.viewManager.insertAtTitle(
resOutput,
commandOption.overwrite,
commandOption.outPrefix,
commandOption.outSuffix,
);
}
}
// Show token usage if available
let tokenInfo = "";
if (currentEngine === ClassifierEngine.JinaAI && jinaResponse && jinaResponse.usage) {
tokenInfo = ` (${jinaResponse.usage.total_tokens} tokens used)`;
}
const engineName = currentEngine === ClassifierEngine.ChatGPT ? "OpenAI-compatible API" : "Jina AI";
new Notice(`${this.manifest.name}: classified with ${limitedOutputs.length} tags using ${engineName}${tokenInfo}.`);
} catch (error: any) {
const engineName = currentEngine === ClassifierEngine.ChatGPT ? "OpenAI-compatible API" : "Jina AI";
new Notice(`${this.manifest.name} API Error: ${error.message || error}`);
console.error(`${engineName} API Error:`, error);
return null;
}
// ------- [Add Tag] -------
// FrontMatter
if (commandOption.outLocation == OutLocation.FrontMatter) {
this.viewManager.insertAtFrontMatter(commandOption.key, resOutput, commandOption.overwrite);
new Notice(`${this.manifest.name}: classified to ${resOutput} at FrontMatter[${commandOption.key}]`);
}
// Title
else if (commandOption.outLocation == OutLocation.Title) {
this.viewManager.insertAtTitle(resOutput, commandOption.overwrite);
new Notice(`${this.manifest.name}: classified to ${resOutput} at Title`);
}
// Cursor
else if (commandOption.outLocation == OutLocation.Cursor) {
this.viewManager.insertAtCursor(resOutput, commandOption.overwrite);
new Notice(`${this.manifest.name}: classified to ${resOutput} at Current Cursor`);
}
}
// create loading spin in the Notice message
createLoadingNotice(text: string, number = 10000): Notice {
const notice = new Notice('', number);
const loadingContainer = document.createElement('div');
loadingContainer.addClass('loading-container');
const loadingIcon = document.createElement('div');
loadingIcon.addClass('loading-icon');
const loadingText = document.createElement('span');
loadingText.textContent = text;
//@ts-ignore
notice.noticeEl.empty();
loadingContainer.appendChild(loadingIcon);
loadingContainer.appendChild(loadingText);
//@ts-ignore
notice.noticeEl.appendChild(loadingContainer);
return notice;
}
}

View file

@ -1,7 +1,13 @@
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import { ChatGPT } from 'src/api';
import { JinaAI } from 'src/jina-api'; // Added JinaAI import
import type AutoClassifierPlugin from "src/main";
import { DEFAULT_CHAT_ROLE, DEFAULT_PROMPT_TEMPLATE } from 'src/template'
import { DEFAULT_CHAT_ROLE, DEFAULT_PROMPT_TEMPLATE, DEFAULT_PROMPT_TEMPLATE_WO_REF } from 'src/template'
export enum ClassifierEngine { // Added ClassifierEngine enum
ChatGPT,
JinaAI,
}
export enum ReferenceType {
All,
@ -10,9 +16,22 @@ export enum ReferenceType {
}
export enum OutLocation {
Cursor,
// Title,
// FrontMatter,
ContentTop,
}
// export enum OutLocation_link {
// Cursor,
// // ContentTop,
// }
export enum OutType {
FrontMatter,
Title,
Cursor,
Tag,
Wikilink,
}
// for tag, keyword
@ -23,38 +42,60 @@ export interface CommandOption {
refType: ReferenceType;
filterRegex: string; // for ReferenceType - Filter
outLocation: OutLocation;
// outLocation_link: OutLocation_link;
outType: OutType;
key: string; // for OutLocation - FrontMatter
outPrefix: string;
outSuffix: string;
overwrite: boolean; // for OutLocation - FrontMatter
useCustomCommand: boolean;
chat_role: string;
prmpt_template: string;
model: string;
max_tokens: number;
max_suggestions: number;
}
export class AutoClassifierSettings {
apiKey: string;
apiKeyCreatedAt: Date | null;
baseURL: string;
classifierEngine: ClassifierEngine; // Added classifierEngine
jinaApiKey: string; // Added jinaApiKey
jinaBaseURL: string; // Added jinaBaseURL
commandOption: CommandOption;
}
export const DEFAULT_SETTINGS: AutoClassifierSettings = {
apiKey: '',
apiKeyCreatedAt: null,
baseURL: 'https://api.openai.com/v1',
classifierEngine: ClassifierEngine.ChatGPT, // Default to ChatGPT
jinaApiKey: 'jina_***', // Default Jina API Key
jinaBaseURL: 'https://api.jina.ai/v1', // Default Jina Base URL
commandOption: {
useRef: true,
refs: [],
manualRefs: [],
refType: ReferenceType.All,
filterRegex: '',
outLocation: OutLocation.FrontMatter,
outLocation: OutLocation.Cursor,
// outLocation_link: OutLocation_link.Cursor,
outType: OutType.Tag,
outPrefix: '',
outSuffix: '',
key: 'tags',
overwrite: false,
useCustomCommand: false,
chat_role: DEFAULT_CHAT_ROLE,
prmpt_template: DEFAULT_PROMPT_TEMPLATE
prmpt_template: DEFAULT_PROMPT_TEMPLATE,
model: "gpt-4.1-mini", // 기본값 및 추천 모델
max_tokens: 150,
max_suggestions: 3,
},
};
@ -82,164 +123,380 @@ export class AutoClassifierSettingTab extends PluginSettingTab {
app.setting.openTabById("hotkeys");
// @ts-ignore
const tab = app.setting.activeTab;
tab.searchInputEl.value = `${this.plugin.manifest.name}:`;
tab.setQuery(this.plugin.manifest.id);
tab.updateHotkeyVisibility();
});
});
shortcutEl.descEl.innerHTML += `
This plugin does not have default shortcuts to prevent shortcut conflicts. <br>
Assign your own shortcuts to run commands for different input types.`
shortcutEl.descEl.createSpan({text: 'This plugin does not have default shortcuts to prevent shortcut conflicts.'});
shortcutEl.descEl.createEl('br');
shortcutEl.descEl.createSpan({text: 'Assign your own shortcuts to run commands for different input types.'});
// ------- [API Setting] -------
// API Key input
containerEl.createEl('h1', { text: 'API Setting' });
const apiKeySetting = new Setting(containerEl)
.setName('ChatGPT API Key')
.setDesc('')
.addText((text) =>
text
.setPlaceholder('API key')
.setValue(this.plugin.settings.apiKey)
.onChange((value) => {
this.plugin.settings.apiKey = value;
this.plugin.saveSettings();
})
)
// API Key Description & Message
apiKeySetting.descEl.innerHTML += 'Enter your ChatGPT API key. If you don\'t have one yet, you can create it at <a href="https://platform.openai.com/account/api-keys">here</a>';
const apiTestMessageEl = document.createElement('div');
apiKeySetting.descEl.appendChild(apiTestMessageEl);
//API Key default message
if (this.plugin.settings.apiKey && this.plugin.settings.apiKeyCreatedAt) {
apiTestMessageEl.setText(`This key was tested at ${this.plugin.settings.apiKeyCreatedAt.toString()}`);
apiTestMessageEl.style.color = 'var(--success-color)';
// Classifier Engine Dropdown
new Setting(containerEl)
.setName('Classifier Engine')
.setDesc('Select the classification engine to use.')
.addDropdown((dropdown) => {
dropdown
.addOption(String(ClassifierEngine.ChatGPT), "OpenAI-compatible API")
.addOption(String(ClassifierEngine.JinaAI), "Jina AI Classifier")
.setValue(String(this.plugin.settings.classifierEngine))
.onChange(async (value) => {
this.plugin.settings.classifierEngine = parseInt(value) as ClassifierEngine;
// Auto-set appropriate default models
if (this.plugin.settings.classifierEngine === ClassifierEngine.ChatGPT) {
if (!commandOption.model || commandOption.model === 'jina-embeddings-v3') {
commandOption.model = 'gpt-4.1-mini';
}
} else if (this.plugin.settings.classifierEngine === ClassifierEngine.JinaAI) {
if (!commandOption.model || commandOption.model === 'gpt-4.1-mini' || commandOption.model === 'gpt-4o' || commandOption.model === 'gpt-4.1') {
commandOption.model = 'jina-embeddings-v3';
}
}
await this.plugin.saveSettings();
this.display(); // Re-render settings to show/hide relevant fields
});
});
// Conditional API Settings
if (this.plugin.settings.classifierEngine === ClassifierEngine.ChatGPT) {
new Setting(containerEl)
.setName('API Base URL')
.setDesc('Base URL for OpenAI-compatible API calls')
.addText((text) =>
text
.setPlaceholder('https://api.openai.com/v1')
.setValue(this.plugin.settings.baseURL)
.onChange((value) => {
this.plugin.settings.baseURL = value;
this.plugin.saveSettings();
})
);
const baseUrlSetting = containerEl.createDiv();
baseUrlSetting.createEl('div', { text: 'Common configurations:' });
baseUrlSetting.createEl('div', { text: '• OpenAI: https://api.openai.com/v1' });
baseUrlSetting.createEl('div', { text: '• Local OpenAI-compatible API (Ollama, LocalAI):' });
baseUrlSetting.createEl('div', { text: ' - Ollama: http://localhost:11434/v1' });
baseUrlSetting.createEl('div', { text: ' - LocalAI: http://localhost:8080/v1' });
baseUrlSetting.createEl('div', { text: ' ⚠️ Ollama/LocalAI are not fully compatible currently.' });
baseUrlSetting.style.marginLeft = '20px';
baseUrlSetting.style.fontSize = '0.9em';
baseUrlSetting.style.color = 'var(--text-muted)';
baseUrlSetting.style.marginBottom = '10px';
new Setting(containerEl)
.setName('Model')
.setDesc("Model ID to use for classification. (Recommended: gpt-4.1-mini, gpt-4.1, gpt-4o)")
.addText((text) =>
text
.setPlaceholder('gpt-4.1-mini')
.setValue(commandOption.model)
.onChange(async (value) => {
commandOption.model = value;
await this.plugin.saveSettings();
})
);
const modelSetting = containerEl.createDiv();
modelSetting.createEl('div', { text: 'Recommended for OpenAI: gpt-4.1-mini, gpt-4.1, gpt-4o' });
modelSetting.createEl('div', { text: '• OpenAI: gpt-4.1-mini, gpt-4.1, gpt-4o' });
modelSetting.createEl('div', { text: '• Local OpenAI-compatible API (Ollama, LocalAI): llama3, mistral, phi3, qwen2 등' });
modelSetting.style.marginLeft = '20px';
modelSetting.style.fontSize = '0.9em';
modelSetting.style.color = 'var(--text-muted)';
modelSetting.style.marginBottom = '10px';
const apiKeySetting = new Setting(containerEl)
.setName('API Key')
.setDesc('')
.addText((text) =>
text
.setPlaceholder('API key (leave empty for local AI)')
.setValue(this.plugin.settings.apiKey)
.onChange((value) => {
this.plugin.settings.apiKey = value;
this.plugin.saveSettings();
})
)
apiKeySetting.descEl.createSpan({ text: 'Enter your API key. Required for OpenAI and etc. Leave empty for local AI (Ollama, LocalAI). ' });
apiKeySetting.descEl.createEl('a', { href: 'https://platform.openai.com/account/api-keys', text: 'Get OpenAI API key' })
const apiTestMessageEl = document.createElement('div');
apiKeySetting.descEl.appendChild(apiTestMessageEl);
if (this.plugin.settings.apiKey && this.plugin.settings.apiKeyCreatedAt) {
apiTestMessageEl.setText(`This key was tested at ${this.plugin.settings.apiKeyCreatedAt.toString()}`);
apiTestMessageEl.style.color = 'var(--success-color)';
}
apiKeySetting.addButton((cb) => {
cb.setButtonText('Test API call')
.setCta()
.onClick(async () => {
apiTestMessageEl.setText('Testing API call...');
apiTestMessageEl.style.color = 'var(--text-normal)';
try {
await ChatGPT.callAPI('', 'test', this.plugin.settings.apiKey, commandOption.model, undefined, undefined, undefined, undefined, undefined, this.plugin.settings.baseURL);
apiTestMessageEl.setText('Success! API working.');
apiTestMessageEl.style.color = 'var(--success-color)';
this.plugin.settings.apiKeyCreatedAt = new Date();
} catch (error) {
apiTestMessageEl.setText('Error: API is not working. Check console for details.');
apiTestMessageEl.style.color = 'var(--warning-color)';
this.plugin.settings.apiKeyCreatedAt = null;
console.error("ChatGPT API Test Error:", error);
}
});
});
} else if (this.plugin.settings.classifierEngine === ClassifierEngine.JinaAI) {
new Setting(containerEl)
.setName('Jina AI API Base URL')
.setDesc('Optional: Set a different base URL for Jina AI API calls.')
.addText((text) =>
text
.setPlaceholder('https://api.jina.ai/v1')
.setValue(this.plugin.settings.jinaBaseURL)
.onChange((value) => {
this.plugin.settings.jinaBaseURL = value;
this.plugin.saveSettings();
})
);
// Note: commandOption.model is currently shared. If Jina needs a separate model field,
// it should be added to AutoClassifierSettings and handled here.
// For now, we'll reuse commandOption.model but default it appropriately.
new Setting(containerEl)
.setName('Jina AI Model')
.setDesc("ID of the Jina AI model to use. Default: jina-embeddings-v3 (supports 8192 tokens, 256 classes).")
.addText((text) =>
text
.setPlaceholder('jina-embeddings-v3')
.setValue(commandOption.model) // Reuse existing model field, ensure it's set to a Jina default if empty or on switch
.onChange(async (value) => {
commandOption.model = value;
await this.plugin.saveSettings();
})
);
const jinaApiKeySetting = new Setting(containerEl)
.setName('Jina AI API Key')
.setDesc('')
.addText((text) =>
text
.setPlaceholder('Jina API key')
.setValue(this.plugin.settings.jinaApiKey)
.onChange((value) => {
this.plugin.settings.jinaApiKey = value;
this.plugin.saveSettings();
})
);
jinaApiKeySetting.descEl.createSpan({ text: 'Enter your Jina AI API key. Get a free API key (10M tokens) at ' });
jinaApiKeySetting.descEl.createEl('a', { href: 'https://jina.ai/', text: 'jina.ai' });
jinaApiKeySetting.descEl.createSpan({ text: '. No account creation often required.' });
const jinaApiTestMessageEl = document.createElement('div');
jinaApiKeySetting.descEl.appendChild(jinaApiTestMessageEl);
// Jina API Key test button
jinaApiKeySetting.addButton((cb) => {
cb.setButtonText('Test Jina API')
.setCta()
.onClick(async () => {
jinaApiTestMessageEl.setText('Testing Jina API call...');
jinaApiTestMessageEl.style.color = 'var(--text-normal)';
try {
// Use a simple test case
const response = await JinaAI.callAPI(
this.plugin.settings.jinaApiKey,
this.plugin.settings.jinaBaseURL,
commandOption.model || 'jina-embeddings-v3', // Fallback to default if model is not set
['This is a test sentence for classification.'],
['positive', 'negative', 'neutral']
);
// Show token usage if available
let tokenInfo = "";
if (response.usage && response.usage.total_tokens) {
tokenInfo = ` (${response.usage.total_tokens} tokens used)`;
}
jinaApiTestMessageEl.setText(`Success! Jina AI API working${tokenInfo}.`);
jinaApiTestMessageEl.style.color = 'var(--success-color)';
} catch (error: any) {
jinaApiTestMessageEl.setText(`Error: ${error.message}`);
jinaApiTestMessageEl.style.color = 'var(--warning-color)';
console.error("Jina AI API Test Error:", error);
}
});
});
}
// API Key test button
apiKeySetting.addButton((cb) => {
cb.setButtonText('Test API call')
.setCta()
.onClick(async () => {
this.plugin.settings.apiKeyCreatedAt
apiTestMessageEl.setText('Testing api call...');
apiTestMessageEl.style.color = 'var(--text-normal)';
try {
await ChatGPT.callAPI('', 'test', this.plugin.settings.apiKey);
apiTestMessageEl.setText('Success! API working.');
apiTestMessageEl.style.color = 'var(--success-color)';
this.plugin.settings.apiKeyCreatedAt = new Date();
} catch (error) {
apiTestMessageEl.setText('Error: API is not working.');
apiTestMessageEl.style.color = 'var(--warning-color)';
this.plugin.settings.apiKeyCreatedAt = null;
}
});
});
// ------- [Tag Reference Setting] -------
containerEl.createEl('h1', { text: 'Tag Reference Setting' });
// Tag Reference Type Dropdown
// Toggle tag reference
new Setting(containerEl)
.setName('Reference type')
.setDesc('Choose the type of reference tag')
.addDropdown((dropdown) => {
dropdown
.addOption(String(ReferenceType.All), "All tags")
.addOption(String(ReferenceType.Filter), "Filtered tags",)
.addOption(String(ReferenceType.Manual), "Manual tags")
.setValue(String(commandOption.refType))
.onChange(async (refTye) => {
this.setRefType(parseInt(refTye));
this.setRefs(parseInt(refTye));
.setName('Use Reference')
.setDesc('If not, it will recommend new tags')
.addToggle((toggle) =>
toggle
.setValue(commandOption.useRef)
.onChange(async (value) => {
commandOption.useRef = value;
await this.plugin.saveSettings();
this.display();
});
});
}),
);
// All tags - default setting
if (commandOption.refType == ReferenceType.All) {
this.setRefs(ReferenceType.All);
}
// Filtered tags - Regex setting
if (commandOption.refType == ReferenceType.Filter) {
if (commandOption.useRef) {
// Tag Reference Type Dropdown
new Setting(containerEl)
.setName('Filter regex')
.setDesc('Specify a regular expression to filter tags')
.setName('Reference type')
.setDesc('Choose the type of reference tag')
.setClass('setting-item-child')
.addText((text) =>
text
.setPlaceholder('Regular expression')
.setValue(commandOption.filterRegex)
.onChange(async (value) => {
this.setRefs(ReferenceType.Filter, value);
})
);
}
// Manual tags - manual input text area
else if (commandOption.refType == ReferenceType.Manual) {
new Setting(containerEl)
.setName('Manual tags')
.setDesc('Manually specify tags to reference.')
.setClass('setting-item-child')
.setClass('height10-text-area')
.addTextArea((text) => {
text
.setPlaceholder('Tags')
.setValue(commandOption.manualRefs?.join('\n'))
.onChange(async (value) => {
this.setRefs(ReferenceType.Manual, value);
})
})
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Bring All Tags')
.onClick(async () => {
const allTags = await this.plugin.viewManager.getTags() ?? [];
commandOption.manualRefs = allTags;
this.setRefs(ReferenceType.Manual);
.addDropdown((dropdown) => {
dropdown
.addOption(String(ReferenceType.All), "All tags")
.addOption(String(ReferenceType.Filter), "Filtered tags",)
.addOption(String(ReferenceType.Manual), "Manual tags")
.setValue(String(commandOption.refType))
.onChange(async (refTye) => {
this.setRefType(parseInt(refTye));
this.setRefs(parseInt(refTye));
this.display();
})
});
});
// All tags - default setting
if (commandOption.refType == ReferenceType.All) {
this.setRefs(ReferenceType.All);
}
// Filtered tags - Regex setting
if (commandOption.refType == ReferenceType.Filter) {
new Setting(containerEl)
.setName('Filter regex')
.setDesc('Specify a regular expression to filter tags')
.setClass('setting-item-child')
.addText((text) =>
text
.setPlaceholder('Regular expression')
.setValue(commandOption.filterRegex)
.onChange(async (value) => {
this.setRefs(ReferenceType.Filter, value);
})
);
}
// Manual tags - manual input text area
else if (commandOption.refType == ReferenceType.Manual) {
new Setting(containerEl)
.setName('Manual tags')
.setDesc('Manually specify tags to reference.')
.setClass('setting-item-child')
.setClass('height10-text-area')
.addTextArea((text) => {
text
.setPlaceholder('Tags')
.setValue(commandOption.manualRefs?.join('\n'))
.onChange(async (value) => {
this.setRefs(ReferenceType.Manual, value);
})
})
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Bring All Tags')
.onClick(async () => {
const allTags = await this.plugin.viewManager.getTags() ?? [];
commandOption.manualRefs = allTags;
this.setRefs(ReferenceType.Manual);
this.display();
})
});
}
// View Reference Tags button
new Setting(containerEl)
.setClass('setting-item-child')
.addButton((cb) => {
cb.setButtonText('View Reference Tags')
.onClick(async () => {
const tags = commandOption.refs ?? [];
let message = `${tags.join('\n')}`;
if (this.plugin.settings.classifierEngine === ClassifierEngine.JinaAI && tags.length > 256) {
message += `\n\n⚠ Warning: Jina AI supports maximum 256 tags, but ${tags.length} were found. Please reduce the number of tags.`;
}
new Notice(message);
});
});
}
// View Reference Tags button
// ------- [Output Setting] -------
containerEl.createEl('h1', { text: 'Output Setting' });
// Output type dropdown
new Setting(containerEl)
.setClass('setting-item-child')
.addButton((cb) => {
cb.setButtonText('View Reference Tags')
.onClick(async () => {
const tags = commandOption.refs ?? [];
new Notice(`${tags.join('\n')}`);
});
});
// ------- [Output Tag Setting] -------
// Tag Location dropdown
containerEl.createEl('h1', { text: 'Output Tag Setting' });
new Setting(containerEl)
.setName('Output Tag Location')
.setDesc('Specify where to put the output tag')
.setName('Output Type')
.setDesc('Specify output type')
.addDropdown((cb) => {
cb.addOption(String(OutLocation.FrontMatter), 'FrontMatter')
.addOption(String(OutLocation.Title), 'Title alternative')
.addOption(String(OutLocation.Cursor), 'Current cursor')
.setValue(String(commandOption.outLocation))
cb.addOption(String(OutType.Tag), '#Tag')
.addOption(String(OutType.Wikilink), '[[Wikilink]]')
.addOption(String(OutType.FrontMatter), 'FrontMatter')
.addOption(String(OutType.Title), 'Title alternative')
.setValue(String(commandOption.outType))
.onChange(async (value) => {
commandOption.outLocation = parseInt(value);
commandOption.outType = parseInt(value);
commandOption.outLocation = 0; // Initialize
await this.plugin.saveSettings();
this.display();
});
});
// Frontmatter - key text setting
let overwriteName = '';
if (commandOption.outLocation == OutLocation.FrontMatter) {
overwriteName = 'Overwrite value of the key';
// Output Type 1. [Tag Case]
if (commandOption.outType == OutType.Tag) {
// Tag - Location dropdown
new Setting(containerEl)
.setName('Output Location')
.setClass('setting-item-child')
.setDesc('Specify where to put the output tag')
.addDropdown((cb) => {
cb.addOption(String(OutLocation.Cursor), 'Current Cursor')
.addOption(String(OutLocation.ContentTop), 'Top of Content')
.setValue(String(commandOption.outLocation))
.onChange(async (value) => {
commandOption.outLocation = parseInt(value);
await this.plugin.saveSettings();
this.display();
});
});
}
// Output Type 2. [Wikilink Case]
else if (commandOption.outType == OutType.Wikilink) {
// Wikilink - Location dropdown
new Setting(containerEl)
.setName('Output Location')
.setClass('setting-item-child')
.setDesc('Specify where to put the output wikilink')
.addDropdown((cb) => {
cb.addOption(String(OutLocation.Cursor), 'Current Cursor')
.addOption(String(OutLocation.ContentTop), 'Top of Content')
.setValue(String(commandOption.outLocation))
.onChange(async (value) => {
commandOption.outLocation = parseInt(value);
await this.plugin.saveSettings();
this.display();
});
});
}
// Output Type 3. [Frontmatter Case]
else if (commandOption.outType == OutType.FrontMatter) {
// key text setting
new Setting(containerEl)
.setName('FrontMatter key')
.setDesc('Specify FrontMatter key to put the output tag')
@ -254,109 +511,284 @@ Assign your own shortcuts to run commands for different input types.`
})
);
}
else if (commandOption.outLocation == OutLocation.Title) {
overwriteName = 'Overwrite whole title';
}
else if (commandOption.outLocation == OutLocation.Cursor) {
overwriteName = 'Overwrite selected area';
// Overwrite setting
if ((commandOption.outType == OutType.Tag && commandOption.outLocation == OutLocation.Cursor) ||
(commandOption.outType == OutType.Wikilink && commandOption.outLocation == OutLocation.Cursor) ||
commandOption.outType == OutType.Title ||
commandOption.outType == OutType.FrontMatter) {
let overwriteName = '';
if (commandOption.outLocation == OutLocation.Cursor) overwriteName = 'Overwrite if selected.';
if (commandOption.outType == OutType.Title) overwriteName = 'Overwrite whole title. If false, add to end of title.';
if (commandOption.outType == OutType.FrontMatter) overwriteName = 'Overwrite value of the key.';
new Setting(containerEl)
.setName(overwriteName)
.setClass('setting-item-child')
.addToggle((toggle) =>
toggle
.setValue(commandOption.overwrite)
.onChange(async (value) => {
commandOption.overwrite = value;
await this.plugin.saveSettings();
this.display();
})
);
}
// Overwrite toggle
// Output Prefix & Suffix
new Setting(containerEl)
.setName(overwriteName)
.setName('Add Prefix & Suffix')
.setDesc(`Output: {prefix} + {output} + {suffix}`);
new Setting(containerEl)
.setName('Prefix')
.setClass('setting-item-child')
.addToggle((toggle) =>
toggle
.setValue(commandOption.overwrite)
.addText((text) =>
text
.setPlaceholder('prefix')
.setValue(commandOption.outPrefix)
.onChange(async (value) => {
commandOption.overwrite = value;
commandOption.outPrefix = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Suffix')
.setClass('setting-item-child')
.addText((text) =>
text
.setPlaceholder('suffix')
.setValue(commandOption.outSuffix)
.onChange(async (value) => {
commandOption.outSuffix = value;
await this.plugin.saveSettings();
})
);
// ------- [Advanced Setting] -------
// Toggle custom rule
containerEl.createEl('h1', { text: 'Advanced Setting' });
new Setting(containerEl)
.setName('Use Custom Request Template')
.addToggle((toggle) =>
toggle
.setValue(commandOption.useCustomCommand)
.setName('Maximum Tag Suggestions')
.setDesc("Maximum number of tags to suggest (1-10)")
.addText((text) =>
text
.setPlaceholder('3')
.setValue(String(commandOption.max_suggestions))
.onChange(async (value) => {
commandOption.useCustomCommand = value;
this.display();
}),
const num = parseInt(value);
if (num >= 1 && num <= 10) {
commandOption.max_suggestions = num;
await this.plugin.saveSettings();
}
})
);
// Custom template textarea
if (commandOption.useCustomCommand) {
const customPromptTemplateEl = new Setting(containerEl)
.setName('Custom Prompt Template')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height20-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom prompt template.')
.setValue(commandOption.prmpt_template)
// Conditional Advanced Settings for OpenAI-compatible API
if (this.plugin.settings.classifierEngine === ClassifierEngine.ChatGPT) {
// Toggle custom rule
new Setting(containerEl)
.setName('Use Custom Request Template')
.setDesc('Enable advanced prompt customization for better results')
.addToggle((toggle) =>
toggle
.setValue(commandOption.useCustomCommand)
.onChange(async (value) => {
commandOption.prmpt_template = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE;
commandOption.useCustomCommand = value;
await this.plugin.saveSettings();
this.display();
})
});
}),
);
customPromptTemplateEl.descEl.innerHTML += `
This plugin is based on the ChatGPT answer.<br>
You can use your own template when making a request to ChatGPT.<br><br>
Variables:<br>
- {{input}}: The text to classify will be inserted here.<br>
- {{reference}}: The reference tags will be inserted here.<br>`;
// Custom template textarea
if (commandOption.useCustomCommand) {
const customChatRoleEl = new Setting(containerEl)
.setName('Custom Chat Role')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height10-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom chat role for gpt system.')
.setValue(commandOption.chat_role)
.onChange(async (value) => {
commandOption.chat_role = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
commandOption.chat_role = DEFAULT_CHAT_ROLE;
await this.plugin.saveSettings();
this.display();
})
});
// Different default template depanding on useRef
if (commandOption.useRef) {
if(commandOption.prmpt_template == DEFAULT_PROMPT_TEMPLATE_WO_REF) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE;
} else {
if(commandOption.prmpt_template == DEFAULT_PROMPT_TEMPLATE) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE_WO_REF;
}
customChatRoleEl.descEl.innerHTML += `
Define custom role to ChatGPT system.`
const customPromptTemplateEl = new Setting(containerEl)
.setName('Custom Prompt Template (ChatGPT)')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height20-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom prompt template.')
.setValue(commandOption.prmpt_template)
.onChange(async (value) => {
commandOption.prmpt_template = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
// Different default template depanding on useRef
if (commandOption.useRef) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE;
else commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE_WO_REF;
await this.plugin.saveSettings();
this.display();
})
});
customPromptTemplateEl.descEl.createSpan({text: 'This plugin is based on the ChatGPT answer.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: 'You can use your own template when making a request to ChatGPT.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: 'Variables:'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: '- {{input}}: The text to classify will be inserted here.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: '- {{reference}}: The reference tags will be inserted here.'});
customPromptTemplateEl.descEl.createEl('br');
const customChatRoleEl = new Setting(containerEl)
.setName('Custom Chat Role (ChatGPT)')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height10-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom chat role for gpt system.')
.setValue(commandOption.chat_role)
.onChange(async (value) => {
commandOption.chat_role = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
commandOption.chat_role = DEFAULT_CHAT_ROLE;
await this.plugin.saveSettings();
this.display();
})
});
customChatRoleEl.descEl.createSpan({text: 'Define custom role to ChatGPT system.'});
new Setting(containerEl)
.setName('Custom Max Tokens (ChatGPT)')
.setDesc("The maximum number of tokens that can be generated in the completion.")
.setClass('setting-item-child')
.addText((text) =>
text
.setPlaceholder('150')
.setValue(String(commandOption.max_tokens))
.onChange(async (value) => {
commandOption.max_tokens = parseInt(value);
await this.plugin.saveSettings();
})
);
}
// Custom template textarea
if (commandOption.useCustomCommand) {
// Different default template depending on useRef
if (commandOption.useRef) {
if(commandOption.prmpt_template == DEFAULT_PROMPT_TEMPLATE_WO_REF) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE;
} else {
if(commandOption.prmpt_template == DEFAULT_PROMPT_TEMPLATE) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE_WO_REF;
}
const customPromptTemplateEl = new Setting(containerEl)
.setName('Custom Prompt Template')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height20-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom prompt template.')
.setValue(commandOption.prmpt_template)
.onChange(async (value) => {
commandOption.prmpt_template = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
// Different default template depending on useRef
if (commandOption.useRef) commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE;
else commandOption.prmpt_template = DEFAULT_PROMPT_TEMPLATE_WO_REF;
await this.plugin.saveSettings();
this.display();
})
});
customPromptTemplateEl.descEl.createSpan({text: 'This plugin is based on the LLM response.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: 'You can use your own template when making a request to the API.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: 'Variables:'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: '- {{input}}: The text to classify will be inserted here.'});
customPromptTemplateEl.descEl.createEl('br');
customPromptTemplateEl.descEl.createSpan({text: '- {{reference}}: The reference tags will be inserted here.'});
customPromptTemplateEl.descEl.createEl('br');
const customChatRoleEl = new Setting(containerEl)
.setName('Custom Chat Role')
.setDesc('')
.setClass('setting-item-child')
.setClass('block-control-item')
.setClass('height10-text-area')
.addTextArea((text) =>
text
.setPlaceholder('Write custom chat role for system.')
.setValue(commandOption.chat_role)
.onChange(async (value) => {
commandOption.chat_role = value;
await this.plugin.saveSettings();
})
)
.addExtraButton(cb => {
cb
.setIcon('reset')
.setTooltip('Restore to default')
.onClick(async () => {
commandOption.chat_role = DEFAULT_CHAT_ROLE;
await this.plugin.saveSettings();
this.display();
})
});
customChatRoleEl.descEl.createSpan({text: 'Define custom role for the AI system.'});
new Setting(containerEl)
.setName('Custom Max Tokens')
.setDesc("The maximum number of tokens that can be generated in the completion.")
.setClass('setting-item-child')
.addText((text) =>
text
.setPlaceholder('150')
.setValue(String(commandOption.max_tokens))
.onChange(async (value) => {
commandOption.max_tokens = parseInt(value);
await this.plugin.saveSettings();
})
);
}
}
// For JinaAI, these custom prompt/role/token settings are hidden as they are not applicable.
}
@ -386,4 +818,4 @@ Define custom role to ChatGPT system.`
}
await this.plugin.saveSettings();
}
}
}

View file

@ -3,9 +3,19 @@ export const DEFAULT_PROMPT_TEMPLATE = `Classify this content:
"""
{{input}}
"""
Answer format is JSON {reliability:0~1, output:selected_category}.
Even if you are not sure, qualify the reliability and select one.
Output must be one of these:
Answer format is JSON {reliability:0~1, outputs:[tag1,tag2,...]}.
Even if you are unsure, qualify the reliability and select the best matches.
Respond only with valid JSON. Do not write an introduction or summary.
Output tags must be from these options:
{{reference}}
`;
`;
export const DEFAULT_PROMPT_TEMPLATE_WO_REF = `Classify this content:
"""
{{input}}
"""
Answer format is JSON {reliability:0~1, output:selected_category}.
Even if you are not sure, qualify the reliability and recommend a proper category.
Respond only with valid JSON. Do not write an introduction or summary.
`;

View file

@ -1,4 +1,5 @@
import { App, MarkdownView, Editor, FrontMatterCache } from "obsidian";
import { OutType } from "src/settings";
export class ViewManager {
app: App;
@ -68,9 +69,10 @@ export class ViewManager {
return tags;
}
async insertAtFrontMatter(key: string, value: string, overwrite = false): Promise<void> {
async insertAtFrontMatter(key: string, value: string, overwrite = false, prefix = '', suffix = ''): Promise<void> {
value = `${prefix}${value}${suffix}`;
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView) {
const file = activeView.file;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
@ -91,7 +93,8 @@ export class ViewManager {
}
}
async insertAtTitle(value: string, overwrite = false): Promise<void> {
async insertAtTitle(value: string, overwrite = false, prefix = '', suffix = ''): Promise<void> {
value = `${prefix}${value}${suffix}`;
const file = this.app.workspace.getActiveFile();
if (!file) return;
let newName = file.basename;
@ -106,9 +109,10 @@ export class ViewManager {
await this.app.fileManager.renameFile(file, newPath);
}
async insertAtCursor(value: string, overwrite = false): Promise<void> {
async insertAtCursor(value: string, overwrite = false, outType: OutType, prefix = '', suffix = ''): Promise<void> {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
let tag = ` #${value} `;
const output = this.preprocessOutput(value, outType, prefix, suffix);
if (activeView) {
const editor = activeView.editor;
const selection = editor.getSelection();
@ -117,8 +121,39 @@ export class ViewManager {
editor.setSelection(editor.getCursor('to'));
}
// overwrite
editor.replaceSelection(tag);
editor.replaceSelection(output);
}
}
async insertAtContentTop(value: string, outType: OutType, prefix = '', suffix = ''): Promise<void> {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const output = this.preprocessOutput(value, outType, prefix, suffix);
if (activeView) {
const editor = activeView.editor;
const file = activeView.file;
const sections = this.app.metadataCache.getFileCache(file)?.sections;
// get the line after frontmatter
let topLine = 0;
if (sections && sections[0].type == "yaml") {
topLine = sections[0].position.end.line + 1;
}
// replace top of the content
editor.setCursor({line: topLine, ch: 0});
editor.replaceSelection(`${output}\n`);
}
}
preprocessOutput(value: string, outType: OutType, prefix = '', suffix = ''): string {
let output = '';
if (outType == OutType.Tag) {
output = `${prefix}${value}${suffix}`;
output = output.replace(/ /g, "_");
output = ` #${output} `;
}
else if (outType == OutType.Wikilink) output = `[[${prefix}${value}${suffix}]]`;
return output
}
}